refactor(core): CORE-16 type display modes and own fullscreen lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 09:42:30 +08:00
1 parent 1b83081e8f
commit 88aef830df
81 files changed
+33897 -1503

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 o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+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,a=Object.prototype.hasOwnProperty,i=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={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.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 a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=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 a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(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
+1200 -460
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -49,7 +49,7 @@
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js",
"test: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": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+175
View File
@@ -4,6 +4,181 @@ 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.
## Display modes (CORE-16, in progress)
player/autoSizeMix.ts, autoHeightMix.ts, aspectRatioMix.ts and flipMix.ts retain the
own-property facades, detached method binding, synchronous returns and normal events.
display/sizing.ts contains geometry only; sizing-types.ts describes the minimal hosts.
Zero/unready/non-finite media or container dimensions defer auto sizing without layout
writes or invalid height events; the next valid call measures and applies normally.
Aspect ratios retain split/Number coercion, datasets, notices and repeated events.
Malformed or unusable ratios do not partially overwrite existing video geometry.
Flip keeps custom strings and falsy normalization. Closing instances ignore these writes.
Run `node --test test/display-sizing.test.js` and
`yarn test:browser test/browser/display-sizing.spec.js` for geometry invariants, old/new
property contracts, hidden-container recovery, notices and destruction. Composition with
screen rotation is tested below; final installed-artifact acceptance remains a CORE-16 gate.
plugins/autoOrientation.ts composes the mobile-only builtin, retaining its name/state
getter and mismatch rule. It rejects unready geometry and scopes the two ArtPlayer event
subscriptions. orientation-types.ts models the minimal host and optional platform lock.
The builtin registry currently bridges its generic JS host at this one assembly boundary;
the complete constructor host remains for CORE-21, without widening the implementation.
display/orientation-web.ts owns delayed rotation and its four inline style properties.
Each new fullscreen session cancels the previous timer. Repeated entry reapplies dimensions
without replacing the first snapshot. On ordinary exit fullscreenWeb has already restored
the full entry style, so rotation only clears its flags; destroy restores rotation properties
before fullscreenWeb performs its own final restoration. Resize may update control CSS
variables independently. The scope cancels timers and removes subscriptions on destruction.
display/orientation-native.ts owns lock requests, error notices and fullscreen rotation
classes. It calls lock synchronously, marks success only while current, and cancels on exit
or destroy. A weak per-platform-object token prevents cancelled or older instances from
unlocking a newer ArtPlayer request. Late cancelled success is released; obsolete failures
cannot overwrite current notices. Unlock is best effort, consistent with the prior API;
arbitrary application calls outside ArtPlayer cannot be tracked as owned locks.
Run `node --test test/display-orientation.test.js` and
`yarn test:browser test/browser/display-orientation.spec.js` for cancellation, retry, shared
screen ownership, repeated entry, styles, timers, real rotated playback and ratio/flip
composition. The browser fixture uses a mobile UA with desktop engines and controlled
orientation.lock; it does not establish physical screen rotation or safe-area device support.
The [Screen Orientation specification](https://www.w3.org/TR/screen-orientation/) permits
platform preconditions for lock and defines superseding requests; capability and device
acceptance must stay distinct from controlled request-state tests.
player/fullscreenWebMix.ts is the boolean property facade. display/web-fullscreen.ts
owns a single entry-session snapshot, reentry generations and destroy restoration;
display/placement.ts captures the original parent and next sibling. Repeated entry
preserves the first snapshot while keeping the existing true events. Exit restores
the captured position even if FULLSCREEN_WEB_IN_BODY changes while active. Destruction
restores the player before template.destroy, so removeHtml still owns the same tree.
Failed exit retains the snapshot for retry; reentrant event/DOM callbacks supersede
the earlier setter. Normal fullscreenWeb/resize ordering remains synchronous.
Destroy is terminal: if restoring the original position throws before reattachment,
detach the displaced player so template cleanup cannot leave an orphan in body. The
original error still propagates; if detachment also fails, the lifecycle collector
retains both failures. A node already returned to its owner is left for normal
removeHtml handling. This exceptional fallback can leave retained HTML detached.
Restore the exact style attribute before the exit event; downstream resize handlers
may update their own CSS variables. display-web.spec.js checks that distinction along
with original placement, repeated entry, failure, reentry and destruction. The native
fullscreen/PiP/mini/sizing/rotation paths have migrated source, while complete
acceptance remain CORE-16; this section does not certify physical mobile devices.
player/fullscreenMix.ts still installs the fullscreen descriptor once after metadata.
display/fullscreen-adapter.ts reuses the unchanged screenfull vendor method mapping;
display/native-fullscreen.ts owns instance state, native events, cancellation and notices.
The getter and exit operation concern this player's container/video, not any document
fullscreen element. Normal fullscreen events precede class changes and resize. Reentrant
setters invalidate stale work while same-state reentry still updates the interface.
display/fullscreen-request.ts invokes the native API on the caller stack, preserving
transient user activation. Native Promise results remain authoritative for their errors;
void prefixed APIs settle from change/error events. Void exit waits until neither the
instance player nor video owns fullscreen, even
when the request target was the player. Another element becoming fullscreen completes
the old exit without giving this instance ownership of that new element. Cancelled calls
settle promptly and lose their instance listeners. Descriptor callers can observe rejection identity;
ordinary assignments report a notice without creating an unhandled rejection.
Unabortable void entry has no Promise to observe after destruction. Only cancellation
of that path creates display/fullscreen-abandoned.ts's document-lifetime change guard.
It keeps weak element keys and native exit operations, never player instances/scopes;
unrelated targets are ignored and a new entry clears the old cancellation. This shared
guard intentionally outlives instances, unlike their ordinary change/error listeners.
Post-destroy native exit is best effort: browser denial cannot be undone synchronously.
Run `node --test test/display-native.test.js` for controlled async/reentry semantics.
For native gesture, recovery and subtitle checks:
```sh
yarn test:browser test/browser/display-native.spec.js test/browser/display-web.spec.js test/browser/subtitle-lifecycle.spec.js
```
Void cancellation in desktop browsers uses controlled native methods; it does not certify
old browser versions or iOS video-only fullscreen. Remaining failure boundaries and
final installed distribution acceptance remain in CORE-16.
display/video-fullscreen.ts owns the video-only WebKit fallback. It retains the
synchronous property setter and native thrown error, and listens to video begin/end,
presentation-mode changes and the existing document event bridge. The state helper in
display/video-fullscreen-state.ts distinguishes fullscreen from PiP when a presentation
mode exists, otherwise reads webkitDisplayingFullscreen or the video event fallback.
Repeated signals emit one transition; an exit failure retains actual state for retry.
Destroy releases all instance listeners and exits active video presentation. A pending
unabortable entry leaves only a static listener on that video with a weak cancellation
mark; this callback references no ArtPlayer/scope and exits late entry. New entry removes
the cancellation. Synchronous exit failure during destroy propagates through the normal
lifecycle cleanup collector; late cancellation cleanup is best effort. No document-wide
listener is installed for this video-only path.
Use `node --test test/display-video-fullscreen.test.js` and
`yarn test:browser test/browser/display-video-fullscreen.spec.js` for controlled fallback
checks, plus native/fullscreenWeb/subtitle tests for composition. The desktop fixture
forces the fallback and emulates native video events; it does not prove physical iOS
presentation or gesture behavior. Apple documents the distinct video events in
[Controlling Media with JavaScript](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/ControllingMediaWithJavaScript/ControllingMediaWithJavaScript.html).
player/pipMix.ts chooses callable native, WebKit or unsupported capabilities and installs
the original property descriptor. display/native-pip.ts keeps the element/null getter,
void setter, native entry/leave events and failure notices. It never exits another
video's PiP; revisions suppress obsolete notices, cancellation rejects late entry, and
destroy disposes listeners before exiting owned presentation. Request invocation remains
synchronous for transient activation. Async rejection is handled internally because the
historical void setter cannot expose a Promise; native synchronous errors still throw.
An old queued leave event is ignored while this video still owns the current PiP session;
actual ownership loss continues to emit the existing false notification.
display/webkit-pip.ts keeps its boolean getter and initial inline setup. It observes both
presentation-mode and PiP events, deduplicates native signals, and retains repeated
synchronous setter notifications. Capability is checked at entry so later media readiness
can allow retry. Cancelled pending entry leaves only a static video-held callback and weak
mark, with no art/scope closure. Inline cleanup never exits a different fullscreen mode.
Destruction's synchronous WebKit failure follows the normal lifecycle error collector;
late cleanup is best effort. Platform UI remains the browser's responsibility.
The internal PipProperty reflects the branch-dependent getter. Historical public boolean
declarations remain unchanged pending BASE-TYPE-10 / CORE-21; this is an explicit legacy
declaration discrepancy, not a reason to convert native JS consumers to boolean.
Run `node --test test/display-pip.test.js` and
`yarn test:browser test/browser/display-pip.spec.js test/browser/display-pip-webkit.spec.js`.
The actual native test records capabilities and, when supported, a native window, media
progress, exit and fullscreenWeb/native-fullscreen transitions. WebKit event injection
is separate from physical Safari/iOS validation. See the
[PiP specification](https://w3c.github.io/picture-in-picture/) and
[Apple's presentation-mode API](https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls).
player/miniMix.ts keeps the boolean mini descriptor. display/mini.ts owns entry/exit
generations and video placement; mini-view.ts owns popup creation, controls, playback
subscriptions and node removal. mini-drag.ts owns document mouse subscriptions and drag
state; mini-layout.ts calculates finite saved/default coordinates and viewport bounds.
The first popup keeps video-first DOM order and its default display; reuse moves the video
last and uses flex as before. Repeated true/false events and storage keys left/top remain.
Capture the original video parent and next sibling once per mini session. Exit restores
that location, with the snapshot retained on failure for retry. Detach the snapshot before
restoring DOM so a reentrant entry can capture its own placement. Serialize entry during
view creation to avoid duplicate popups from custom icon getters. Failed creation disposes
its partial view; hide/destroy during construction never leaves visible stale UI.
Created popups and playback/drag listeners belong to the instance scope; hide keeps a
reusable hidden popup but cancels drag, and destroy removes owned nodes and restores the
video. A caller-provided template.$mini stays caller-owned and regains its prior display
on destroy. Drag uses client coordinates for fixed positioning, clamps its final position,
and writes numeric left/top. Normal defaults retain a 50px inset; non-finite/offscreen
storage resets, and CSS max-width/max-height keep the popup inside a smaller viewport.
Run `node --test test/display-mini.test.js` and
`yarn test:browser test/browser/display-mini.spec.js test/browser/display-pip.spec.js`.
Browser checks include video placement, playback buttons, actual drag, failure/reentry,
caller ownership, narrow screens and transitions from mini to web fullscreen/native PiP.
Final installed display-mode acceptance is still pending with CORE-16.
## Subtitles (CORE-15)
src/subtitle/index.ts keeps the Component prototype, bound update, public methods 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 o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+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,a=Object.prototype.hasOwnProperty,i=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={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.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 a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=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 a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(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
+1200 -460
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
import type { NativeFullscreenAdapter } from './types'
const abandoned = new WeakMap<Document, WeakMap<Element, () => unknown>>()
export function hasAbandonedFullscreen(adapter: NativeFullscreenAdapter): boolean {
return abandoned.get(adapter.document)?.has(adapter.target) ?? false
}
export function clearAbandonedFullscreen(adapter: NativeFullscreenAdapter): void {
abandoned.get(adapter.document)?.delete(adapter.target)
}
function exitNative(exit: () => unknown): void {
try {
void Promise.resolve(exit()).catch(() => {})
}
catch {}
}
function entriesFor(document: Document, elementProperty: string, changeEvent: string): WeakMap<Element, () => unknown> {
let entries = abandoned.get(document)
if (!entries) {
entries = new WeakMap()
abandoned.set(document, entries)
const pending = entries
// A document-lifetime guard for unabortable void APIs. Keys are weak and values
// contain only the native exit operation, never an ArtPlayer instance or scope.
document.addEventListener(changeEvent, () => {
const element = Reflect.get(document, elementProperty) as Element | null
const release = element && pending.get(element)
if (!release)
return
pending.delete(element)
exitNative(release)
})
}
return entries
}
export function abandonFullscreen(adapter: NativeFullscreenAdapter): void {
const { document, elementProperty, changeEvent, target, exit } = adapter
const entries = entriesFor(document, elementProperty, changeEvent)
entries.set(target, exit)
if (adapter.element === target) {
entries.delete(target)
exitNative(exit)
}
}
@@ -0,0 +1,38 @@
import type { NativeFullscreenAdapter } from './types'
import screenfull from '../libs/screenfull'
interface MethodMap {
requestFullscreen: string
exitFullscreen: string
fullscreenElement: string
fullscreenEnabled: string
fullscreenchange: string
fullscreenerror: string
}
function call(target: object, name: string, ...args: unknown[]): unknown {
const method: unknown = Reflect.get(target, name)
if (typeof method !== 'function')
throw new TypeError(`Fullscreen method ${name} is not available`)
return method.apply(target, args)
}
export function fullscreenAdapter(target: HTMLElement): NativeFullscreenAdapter | undefined {
// Reuse the audited vendor mapping, not its unscoped request-promise listeners.
const raw = screenfull.raw as MethodMap | false
const document = target.ownerDocument
if (!raw || !Reflect.get(document, raw.fullscreenEnabled))
return undefined
return {
document,
target,
get element(): Element | null {
return Reflect.get(document, raw.fullscreenElement) as Element | null
},
elementProperty: raw.fullscreenElement,
changeEvent: raw.fullscreenchange,
errorEvent: raw.fullscreenerror,
request: () => call(target, raw.requestFullscreen, undefined),
exit: () => call(document, raw.exitFullscreen),
}
}
@@ -0,0 +1,88 @@
import type ResourceScope from '../lifecycle/scope'
import type { NativeFullscreenAdapter } from './types'
import { listen } from '../lifecycle/resources'
import { abandonFullscreen, clearAbandonedFullscreen } from './fullscreen-abandoned'
export function requestFullscreen(adapter: NativeFullscreenAdapter, parent: ResourceScope, entering: boolean, lateCompletion: () => void, hasExited = () => adapter.element !== adapter.target): { promise: Promise<void>, cancel: () => void } {
const scope = parent.child()
let done = false
let cancelled = false
let returned = false
let promiseResult = false
let eventFailed = false
let resolve!: () => void
let reject!: (error: unknown) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
scope.add(() => {
if (!done) {
done = true
cancelled = true
resolve()
if (returned && !promiseResult && entering)
abandonFullscreen(adapter)
}
})
function finish(failed = false, error?: unknown) {
if (done)
return
done = true
try {
scope.dispose()
}
catch (cleanupError) {
if (!failed) {
failed = true
error = cleanupError
}
}
if (failed)
reject(error)
else
resolve()
}
const changed = () => {
if (returned && !promiseResult && (entering ? adapter.element === adapter.target : hasExited()))
finish()
}
listen(scope, adapter.document, adapter.changeEvent, changed)
listen(scope, adapter.document, adapter.errorEvent, (event) => {
if (event.target === adapter.target || event.target === adapter.document) {
eventFailed = true
if (returned && !promiseResult)
finish(true, new Error('Fullscreen request failed'))
}
})
if (!scope.closed) {
try {
// Invoke before yielding so the browser sees the caller's transient activation.
if (entering)
clearAbandonedFullscreen(adapter)
const result = entering ? adapter.request() : adapter.exit()
promiseResult = Boolean(result && typeof (result as PromiseLike<unknown>).then === 'function')
returned = true
if (cancelled && !promiseResult && entering)
abandonFullscreen(adapter)
if (promiseResult) {
void Promise.resolve(result).then(() => {
if (cancelled)
lateCompletion()
else
finish()
}, error => finish(true, error)).catch(() => {})
}
else if (eventFailed) {
finish(true, new Error('Fullscreen request failed'))
}
else {
changed()
}
}
catch (error) {
finish(true, error)
}
}
return { promise, cancel: () => scope.dispose() }
}
@@ -0,0 +1,51 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { listen } from '../lifecycle/resources'
import { clampMini, miniGeometry } from './mini-layout'
export function miniDrag(art: MiniHost, element: HTMLElement, scope: ResourceScope, active: () => boolean): () => void {
let dragging = false
let startX = 0
let startY = 0
const cancel = (): undefined => {
dragging = false
element.classList.remove('art-mini-dragging')
element.style.transform = ''
}
listen(scope, element, 'mousedown', (event) => {
const mouse = event as MouseEvent
if (mouse.button !== 0 || !active())
return
dragging = true
startX = mouse.clientX
startY = mouse.clientY
})
const move = (event: MouseEvent) => {
if (!scope.closed && dragging && active()) {
element.classList.add('art-mini-dragging')
element.style.transform = `translate(${event.clientX - startX}px, ${event.clientY - startY}px)`
}
}
const end = () => {
if (scope.closed || !dragging || !active())
return
const rect = element.getBoundingClientRect()
const position = clampMini(rect.left, rect.top, miniGeometry(element))
cancel()
element.style.left = `${position.left}px`
element.style.top = `${position.top}px`
art.storage.set('left', position.left)
if (!scope.closed)
art.storage.set('top', position.top)
}
art.on('document:mousemove', move)
scope.add(() => {
art.off('document:mousemove', move)
})
art.on('document:mouseup', end)
scope.add(() => {
art.off('document:mouseup', end)
})
scope.add(cancel)
return cancel
}
@@ -0,0 +1,24 @@
export interface MiniGeometry {
width: number
height: number
viewportWidth: number
viewportHeight: number
}
export function clampMini(left: number, top: number, geometry: MiniGeometry): { left: number, top: number } {
const maxLeft = Math.max(0, geometry.viewportWidth - geometry.width)
const maxTop = Math.max(0, geometry.viewportHeight - geometry.height)
return { left: Math.max(0, Math.min(left, maxLeft)), top: Math.max(0, Math.min(top, maxTop)) }
}
export function miniPosition(left: unknown, top: unknown, geometry: MiniGeometry): { left: number, top: number, reset: boolean } {
if (typeof left === 'number' && Number.isFinite(left) && typeof top === 'number' && Number.isFinite(top)
&& left >= 0 && top >= 0 && left + geometry.width <= geometry.viewportWidth && top + geometry.height <= geometry.viewportHeight) { return { left, top, reset: false } }
return { ...clampMini(geometry.viewportWidth - geometry.width - 50, geometry.viewportHeight - geometry.height - 50, geometry), reset: true }
}
export function miniGeometry(element: HTMLElement): MiniGeometry {
const { width, height } = element.getBoundingClientRect()
const view = element.ownerDocument.defaultView!
return { width, height, viewportWidth: view.innerWidth, viewportHeight: view.innerHeight }
}
@@ -0,0 +1,79 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { listen } from '../lifecycle/resources'
import { silencePromise } from '../utils/error'
import { miniDrag } from './mini-drag'
export interface MiniView {
element: HTMLElement
cancelDrag: () => void
fresh: boolean
}
export function createMiniView(art: MiniHost, parent: ResourceScope, active: () => boolean, hide: () => void): MiniView | undefined {
const scope = parent.child()
if (scope.closed)
return
const existing = art.template.$mini
if (existing) {
const display = existing.style.display
scope.add(() => {
existing.style.display = display
})
return { element: existing, cancelDrag: () => {}, fresh: false }
}
const document = art.template.$player.ownerDocument
const element = document.createElement('div')
element.className = 'art-mini-popup'
scope.add(() => {
element.remove()
})
try {
const close = document.createElement('div')
close.className = 'art-mini-close'
close.append(art.icons.close)
if (scope.closed)
return
const state = document.createElement('div')
state.className = 'art-mini-state'
const play = art.icons.play
const pause = art.icons.pause
if (scope.closed)
return
state.append(play, pause)
element.append(close, state)
listen(scope, close, 'click', hide)
listen(scope, play, 'click', () => {
silencePromise(art.play())
})
listen(scope, pause, 'click', () => {
art.pause()
})
const update = () => {
if (scope.closed)
return
play.style.display = art.playing ? 'none' : 'flex'
pause.style.display = art.playing ? 'flex' : 'none'
}
update()
for (const name of ['video:playing', 'video:pause', 'video:timeupdate'] as const) {
art.on(name, update)
scope.add(() => {
art.off(name, update)
})
}
const cancelDrag = miniDrag(art, element, scope, active)
if (scope.closed)
return
art.template.$mini = element
document.body.append(element)
if (!scope.closed)
return { element, cancelDrag, fresh: true }
}
catch (error) {
scope.dispose()
if (art.template.$mini === element)
delete art.template.$mini
throw error
}
}
+142
View File
@@ -0,0 +1,142 @@
import type { MiniView } from './mini-view'
import type { Placement } from './placement'
import type { MiniHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { miniGeometry, miniPosition } from './mini-layout'
import { createMiniView } from './mini-view'
import { capturePlacement, restorePlacement } from './placement'
export function mini(art: MiniHost): PropertyDescriptor {
const { $player, $video } = art.template
const scope = getScope(art).child()
let view: MiniView | undefined
let placement: Placement | undefined
let revision = 0
let creating = false
let queued = false
const active = () => !isClosing(art) && $player.classList.contains('art-mini')
function restore() {
if (placement) {
const saved = placement
const current = revision
placement = undefined
try {
restorePlacement(saved)
}
catch (error) {
if (current === revision && !placement)
placement = saved
throw error
}
}
}
function hide() {
if (isClosing(art))
return
const current = ++revision
queued = false
restore()
if (isClosing(art) || current !== revision)
return
view?.cancelDrag()
$player.classList.remove('art-mini')
if (art.template.$mini) {
art.template.$mini.style.display = 'none'
art.emit('mini', false)
}
}
scope.add(() => {
revision++
try {
restore()
}
finally {
view?.cancelDrag()
$player.classList.remove('art-mini')
}
})
function show(): void {
if (isClosing(art))
return
const current = ++revision
if (creating) {
queued = true
return
}
art.state = 'mini'
if (isClosing(art) || current !== revision)
return
placement ??= capturePlacement($video)
$player.classList.add('art-mini')
try {
if (!view) {
creating = true
try {
view = createMiniView(art, scope, active, hide)
}
finally {
creating = false
}
}
if (queued) {
queued = false
show()
return
}
if (!view || isClosing(art))
return
if (current !== revision) {
if (!active())
view.element.style.display = 'none'
return
}
if (view.fresh)
view.element.prepend($video)
else
view.element.append($video)
if (isClosing(art) || current !== revision)
return
view.element.style.display = view.fresh ? '' : 'flex'
view.fresh = false
const top = art.storage.get('top')
const left = art.storage.get('left')
const position = miniPosition(left, top, miniGeometry(view.element))
if (isClosing(art) || current !== revision)
return
view.element.style.left = `${position.left}px`
view.element.style.top = `${position.top}px`
if (position.reset) {
art.storage.set('top', position.top)
if (isClosing(art) || current !== revision)
return
art.storage.set('left', position.left)
}
if (!isClosing(art) && current === revision)
art.emit('mini', true)
}
catch (error) {
const pendingReentry = queued
queued = false
if (!isClosing(art) && (current === revision || pendingReentry)) {
restore()
$player.classList.remove('art-mini')
if (view)
view.element.style.display = 'none'
}
throw error
}
}
return {
get: active,
set(value: boolean): void {
if (value)
show()
else
hide()
},
}
}
@@ -0,0 +1,106 @@
import type { FullscreenHost, NativeFullscreenAdapter } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { hasAbandonedFullscreen } from './fullscreen-abandoned'
import { requestFullscreen } from './fullscreen-request'
export function nativeFullscreen(art: FullscreenHost, adapter: NativeFullscreenAdapter): PropertyDescriptor {
const scope = getScope(art).child()
const { $player, $video } = art.template
const ownsFullscreen = () => adapter.element === $player || adapter.element === $video
let last = ownsFullscreen()
let revision = 0
let desired = last
let pending: ReturnType<typeof requestFullscreen> | undefined
let pendingEntry = false
let cancelledEntry = false
let requesting = false
function exitOwned() {
if (!ownsFullscreen())
return
try {
void Promise.resolve(adapter.exit()).catch(() => {})
}
catch {}
}
scope.add(() => {
revision += 1
desired = false
$player.classList.remove('art-fullscreen')
exitOwned()
})
listen(scope, adapter.document, adapter.changeEvent, () => {
const value = ownsFullscreen()
if (value && hasAbandonedFullscreen(adapter))
return
if (value && cancelledEntry) {
exitOwned()
return
}
if (value === last || isClosing(art))
return
last = value
const current = revision
const stale = () => isClosing(art) || ownsFullscreen() !== value || (current !== revision && desired !== value)
art.emit('fullscreen', value)
if (stale())
return
if (value)
art.state = 'fullscreen'
if (stale())
return
$player.classList.toggle('art-fullscreen', value)
art.emit('resize')
})
listen(scope, adapter.document, adapter.errorEvent, (event) => {
const ownEvent = event.target === $player || event.target === $video
|| (event.target === adapter.document && (requesting || pending !== undefined || ownsFullscreen()))
if (ownEvent && !isClosing(art))
art.emit('fullscreenError', event)
})
return {
get: () => !isClosing(art) && ownsFullscreen(),
set(value: boolean): Promise<void> {
if (isClosing(art))
return Promise.resolve()
const current = ++revision
desired = Boolean(value)
if (desired)
cancelledEntry = false
else if (pendingEntry)
cancelledEntry = true
pending?.cancel()
pending = undefined
pendingEntry = false
if (!desired && !ownsFullscreen())
return Promise.resolve()
requesting = true
pendingEntry = desired
const operation = requestFullscreen(adapter, scope, desired, () => {
if (isClosing(art) || !desired)
exitOwned()
}, () => !ownsFullscreen())
requesting = false
if (revision === current)
pending = operation
else
operation.cancel()
const result = operation.promise.catch((error) => {
if (!isClosing(art) && revision === current)
art.notice.show = error
throw error
}).finally(() => {
if (revision === current) {
pending = undefined
pendingEntry = false
}
})
// Assignment cannot consume a setter return, but descriptor callers still can.
void result.catch(() => {})
return result
},
}
}
@@ -0,0 +1,98 @@
import type { PipHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
export function nativePip(art: PipHost): PropertyDescriptor {
const { $video } = art.template
const document = $video.ownerDocument
const scope = getScope(art).child()
const owns = () => document.pictureInPictureElement === $video
let last = owns()
let desired = last
let pending = false
let cancelled = false
let revision = 0
$video.disablePictureInPicture = false
function exitOwned() {
if (!owns())
return
try {
void document.exitPictureInPicture().catch(() => {})
}
catch {}
}
function observe(result: Promise<unknown>, entering: boolean, current: number) {
void result.then(() => {
if (entering && (isClosing(art) || !desired))
exitOwned()
}, (error) => {
if (!isClosing(art) && revision === current)
art.notice.show = error
}).finally(() => {
if (revision === current)
pending = false
}).catch(() => {})
}
scope.add(() => {
revision++
desired = false
exitOwned()
})
listen(scope, $video, 'enterpictureinpicture', () => {
if (cancelled) {
exitOwned()
return
}
if (!owns() || last)
return
const current = revision
art.state = 'pip'
if (!isClosing(art) && current === revision && owns()) {
last = true
art.emit('pip', true)
}
})
listen(scope, $video, 'leavepictureinpicture', () => {
if (!last || owns())
return
last = false
art.emit('pip', false)
})
return {
// Native callers historically receive the media element, not a boolean.
get: () => !isClosing(art) && owns() ? $video : null,
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
desired = Boolean(value)
if (desired)
cancelled = false
else if (pending)
cancelled = true
pending = false
if (desired) {
art.state = 'pip'
if (isClosing(art) || revision !== current)
return
pending = true
try {
// Native invocation stays on the user gesture stack; the setter stays void.
observe($video.requestPictureInPicture!(), true, current)
}
catch (error) {
if (revision === current)
pending = false
throw error
}
}
else if (owns()) {
observe(document.exitPictureInPicture(), false, current)
}
},
}
}
@@ -0,0 +1,87 @@
import type { OrientationHost, OrientationLock } from './orientation-types'
import { getScope, isClosing } from '../lifecycle/instance'
interface Request {
orientation: OrientationLock
cancelled: boolean
settled: boolean
}
// Per-screen ownership prevents an old instance from unlocking a newer request.
const owners = new WeakMap<OrientationLock, Request>()
const className = 'art-auto-orientation-fullscreen'
function unlock(request: Request): void {
if (owners.get(request.orientation) !== request)
return
try {
request.orientation.unlock()
}
catch {}
}
export function nativeOrientation(art: OrientationHost, needRotate: () => boolean): (state: boolean) => void {
const { $player } = art.template
let current: Request | undefined
function cancel(): undefined {
const request = current
current = undefined
$player.classList.remove(className)
if (request) {
request.cancelled = true
unlock(request)
if (request.settled && owners.get(request.orientation) === request)
owners.delete(request.orientation)
}
}
getScope(art).add(cancel)
return (state) => {
if (isClosing(art))
return
if (!state) {
cancel()
return
}
if (current && !current.cancelled && owners.get(current.orientation) === current)
return
const orientation = $player.ownerDocument.defaultView?.screen?.orientation as OrientationLock | undefined
if (typeof orientation?.lock !== 'function' || typeof orientation.unlock !== 'function' || !needRotate())
return
const request: Request = { orientation, cancelled: false, settled: false }
current = request
owners.set(orientation, request)
const active = () => !isClosing(art) && !request.cancelled && current === request && owners.get(orientation) === request
const failed = (error: unknown) => {
request.settled = true
const show = active()
if (owners.get(orientation) === request)
owners.delete(orientation)
if (current === request)
current = undefined
if (show) {
$player.classList.remove(className)
art.notice.show = error
}
}
try {
// Invoke before yielding; fullscreen entry already supplied the gesture.
const result = orientation.lock(orientation.type.startsWith('portrait') ? 'landscape' : 'portrait')
Promise.resolve(result).then(() => {
request.settled = true
if (active()) {
$player.classList.add(className)
}
else {
unlock(request)
if (owners.get(orientation) === request)
owners.delete(orientation)
}
}).catch(failed)
}
catch (error) {
failed(error)
}
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface OrientationHost extends SubscriptionHost<{ fullscreenWeb: [boolean], fullscreen: [boolean] }> {
constructor: { AUTO_ORIENTATION_TIME?: number }
template: { $player: HTMLElement, $video: { readonly videoWidth: number, readonly videoHeight: number } }
fullscreenWeb: boolean
isRotate: boolean
notice: { set show(value: unknown) }
emit: (name: 'resize') => unknown
}
export interface OrientationLock {
readonly type: string
lock: (mode: 'portrait' | 'landscape') => Promise<void>
unlock: () => void
}
@@ -0,0 +1,72 @@
import type { OrientationHost } from './orientation-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { getSafeAreaInsets } from '../utils'
const className = 'art-auto-orientation'
const properties = ['width', 'height', 'transform-origin', 'transform'] as const
export function webOrientation(art: OrientationHost, needRotate: () => boolean): (state: boolean) => void {
const { $player } = art.template
const scope = getScope(art)
let cancel = () => {}
let saved: { name: string, value: string, priority: string }[] | undefined
function clear(): undefined {
cancel()
cancel = () => {}
const previous = saved
saved = undefined
// fullscreenWeb restores its complete entry style before emitting false.
if (previous && art.fullscreenWeb) {
for (const { name, value, priority } of previous)
$player.style.setProperty(name, value, priority)
}
const wasRotated = $player.classList.contains(className)
$player.classList.remove(className)
if (wasRotated) {
art.isRotate = false
if (!isClosing(art))
art.emit('resize')
}
}
scope.add(clear)
function apply(emit: boolean): void {
if (!art.fullscreenWeb || !needRotate())
return
const insets = getSafeAreaInsets()
if (isClosing(art) || !art.fullscreenWeb)
return
const viewport = $player.ownerDocument.documentElement
saved ??= properties.map(name => ({ name, value: $player.style.getPropertyValue(name), priority: $player.style.getPropertyPriority(name) }))
$player.style.width = `${viewport.clientHeight - insets.top - insets.bottom}px`
$player.style.height = `${viewport.clientWidth - insets.left - insets.right}px`
$player.style.transformOrigin = '0 0'
$player.style.transform = `rotate(90deg) translate(${insets.top}px, -${viewport.clientWidth - insets.right}px)`
$player.classList.add(className)
art.isRotate = true
if (emit)
art.emit('resize')
}
return (state) => {
if (isClosing(art))
return
if (!state) {
clear()
return
}
if (saved) {
apply(false)
return
}
if (!needRotate())
return
cancel()
cancel = timeout(scope, () => {
if (!saved)
apply(true)
}, Number(art.constructor.AUTO_ORIENTATION_TIME ?? 0))
}
}
@@ -0,0 +1,19 @@
export interface Placement {
node: Node
parent: ParentNode | null
next: ChildNode | null
}
export function capturePlacement(node: Node): Placement {
return { node, parent: node.parentNode, next: node.nextSibling }
}
export function restorePlacement({ node, parent, next }: Placement): void {
if (!parent) {
node.parentNode?.removeChild(node)
return
}
const anchor = next?.parentNode === parent ? next : null
if (node.parentNode !== parent || node.nextSibling !== anchor)
parent.insertBefore(node, anchor)
}
@@ -0,0 +1,29 @@
import type { PlaybackNotice } from '../media/playback'
import type { Size } from './sizing'
interface MediaDimensions {
readonly videoWidth: number
readonly videoHeight: number
}
export interface AutoSizeHost {
template: { $container: HTMLElement, $player: HTMLElement, $video: MediaDimensions }
readonly width: number
readonly height: number
emit: (name: 'autoSize', size: Size) => unknown
}
export interface AutoHeightHost {
template: { $container: HTMLElement, $video: MediaDimensions }
emit: (name: 'autoHeight', height: number) => unknown
}
export interface AspectRatioHost extends PlaybackNotice {
template: { $player: HTMLElement, $video: HTMLElement }
emit: (name: 'aspectRatio', ratio: string) => unknown
}
export interface FlipHost extends PlaybackNotice {
template: { $player: HTMLElement }
emit: (name: 'flip', flip: string) => unknown
}
+24
View File
@@ -0,0 +1,24 @@
export interface Size {
width: number
height: number
}
export function positiveSize({ width, height }: Size): boolean {
return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0
}
export function containSize(container: Size, ratio: number): (Size & { axis: 'width' | 'height' }) | undefined {
if (!positiveSize(container) || !Number.isFinite(ratio) || ratio <= 0)
return
const result = container.width / container.height > ratio
? { width: ratio * container.height, height: container.height, axis: 'width' as const }
: { width: container.width, height: container.width / ratio, axis: 'height' as const }
return positiveSize(result) ? result : undefined
}
export function proportionalHeight(width: number, media: Size): number | undefined {
if (!Number.isFinite(width) || width <= 0 || !positiveSize(media))
return
const height = media.height * (width / media.width)
return Number.isFinite(height) && height > 0 ? height : undefined
}
+71
View File
@@ -0,0 +1,71 @@
import type { SubscriptionHost } from '../component/resources'
import type { OptionalMediaCapabilities } from '../media/types'
export interface WebFullscreenHost {
constructor: { FULLSCREEN_WEB_IN_BODY: boolean }
template: { $container: HTMLElement, $player: HTMLElement }
state: string
emit: (name: 'fullscreenWeb' | 'resize', ...args: unknown[]) => unknown
}
export interface WebFullscreenProperty {
fullscreenWeb: boolean
}
export interface FullscreenHost {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities }
state: string
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
emit: (name: 'fullscreen' | 'fullscreenError' | 'resize', ...args: unknown[]) => unknown
once: (name: 'video:loadedmetadata', listener: () => void) => unknown
on: (name: 'document:webkitfullscreenchange', listener: () => void) => unknown
off: (name: 'video:loadedmetadata' | 'document:webkitfullscreenchange', listener: () => void) => unknown
}
export interface NativeFullscreenAdapter {
document: Document
target: HTMLElement
readonly element: Element | null
elementProperty: string
changeEvent: string
errorEvent: string
request: () => unknown
exit: () => unknown
}
export interface PipHost {
template: { $video: HTMLElement & OptionalMediaCapabilities & { disablePictureInPicture?: boolean } }
state: string
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
emit: (name: 'pip', value: boolean) => unknown
}
export interface PipProperty {
get pip(): HTMLElement | null | boolean
set pip(value: boolean)
}
export interface MiniEvents {
'video:playing': []
'video:pause': []
'video:timeupdate': []
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
}
export interface MiniHost extends SubscriptionHost<MiniEvents> {
template: { $player: HTMLElement, $video: HTMLElement, $mini?: HTMLElement }
icons: { close: HTMLElement, play: HTMLElement, pause: HTMLElement }
storage: { get: (key: string) => unknown, set: (key: string, value: number) => unknown }
state: string
playing: boolean
play: () => unknown
pause: () => unknown
emit: (name: 'mini', value: boolean) => unknown
}
export interface MiniProperty {
mini: boolean
}
@@ -0,0 +1,40 @@
import type { OptionalMediaCapabilities } from '../media/types'
type Video = HTMLElement & OptionalMediaCapabilities
const abandoned = new WeakSet<Video>()
export function videoIsFullscreen(video: Video, observed = false): boolean {
if (typeof video.webkitPresentationMode === 'string')
return video.webkitPresentationMode === 'fullscreen'
if (typeof video.webkitDisplayingFullscreen === 'boolean')
return video.webkitDisplayingFullscreen
return video.ownerDocument.fullscreenElement === video || observed
}
export function clearAbandonedVideo(video: Video): void {
abandoned.delete(video)
video.removeEventListener('webkitbeginfullscreen', releaseAbandonedVideo)
video.removeEventListener('webkitpresentationmodechanged', releaseAbandonedVideo)
}
function releaseAbandonedVideo(this: Video, event: Event): void {
if (!abandoned.has(this) || !videoIsFullscreen(this, event.type === 'webkitbeginfullscreen'))
return
clearAbandonedVideo(this)
try {
this.webkitExitFullscreen?.()
}
catch {}
}
export function abandonVideo(video: Video): void {
abandoned.add(video)
// The listener is held by the video itself, with no instance or scope closure.
video.addEventListener('webkitbeginfullscreen', releaseAbandonedVideo)
video.addEventListener('webkitpresentationmodechanged', releaseAbandonedVideo)
}
export function isAbandonedVideo(video: Video): boolean {
return abandoned.has(video)
}
@@ -0,0 +1,87 @@
import type { FullscreenHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { abandonVideo, clearAbandonedVideo, isAbandonedVideo, videoIsFullscreen } from './video-fullscreen-state'
export function videoFullscreen(art: FullscreenHost): PropertyDescriptor {
const { $video } = art.template
const scope = getScope(art).child()
let observed = false
let last = videoIsFullscreen($video)
let desired = last
let pending = false
let revision = 0
const active = () => videoIsFullscreen($video, observed)
function changed(hint?: boolean) {
if (isClosing(art) || isAbandonedVideo($video))
return
if (typeof hint === 'boolean')
observed = hint
const value = active()
if (value)
pending = false
if (value === last)
return
last = value
const current = revision
const stale = () => isClosing(art) || active() !== value || (current !== revision && desired !== value)
art.emit('fullscreen', value)
if (stale())
return
if (value)
art.state = 'fullscreen'
if (!stale())
art.emit('resize')
}
scope.add(() => {
revision++
desired = false
if (pending)
abandonVideo($video)
if (active())
$video.webkitExitFullscreen?.()
})
listen(scope, $video, 'webkitbeginfullscreen', () => changed(true))
listen(scope, $video, 'webkitendfullscreen', () => changed(false))
listen(scope, $video, 'webkitpresentationmodechanged', () => changed())
const documentChanged = () => changed()
art.on('document:webkitfullscreenchange', documentChanged)
scope.add(() => {
art.off('document:webkitfullscreenchange', documentChanged)
})
return {
get: () => !isClosing(art) && active(),
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
desired = Boolean(value)
if (value) {
clearAbandonedVideo($video)
art.state = 'fullscreen'
if (isClosing(art) || current !== revision)
return
pending = true
try {
$video.webkitEnterFullscreen!()
}
catch (error) {
if (current === revision)
pending = false
throw error
}
}
else {
if (pending) {
pending = false
abandonVideo($video)
}
if (active())
$video.webkitExitFullscreen!()
}
},
}
}
@@ -0,0 +1,99 @@
import type { Placement } from './placement'
import type { WebFullscreenHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { capturePlacement, restorePlacement } from './placement'
interface Snapshot {
placement: Placement
style: string | null
}
export function webFullscreen(art: WebFullscreenHost): (value: boolean) => void {
const { $player } = art.template
let saved: Snapshot | undefined
let revision = 0
function restore(snapshot: Snapshot, active: () => boolean): boolean {
if (snapshot.style === null)
$player.removeAttribute('style')
else
$player.setAttribute('style', snapshot.style)
if (!active())
return false
$player.classList.remove('art-fullscreen-web')
if (!active())
return false
restorePlacement(snapshot.placement)
return active()
}
getScope(art).add(() => {
revision += 1
const snapshot = saved
saved = undefined
if (snapshot) {
try {
restore(snapshot, () => true)
}
catch (error) {
// Destruction cannot retry: detach only if restoration left our node outside its owner.
try {
if ($player.parentNode !== snapshot.placement.parent)
$player.parentNode?.removeChild($player)
}
catch (detachError) {
throw new ResourceCleanupError([error, detachError])
}
throw error
}
}
})
return (value) => {
if (isClosing(art))
return
const current = ++revision
const active = () => current === revision && !isClosing(art)
if (value) {
if (!saved)
saved = { placement: capturePlacement($player), style: $player.getAttribute('style') }
const snapshot = saved
try {
if (art.constructor.FULLSCREEN_WEB_IN_BODY && $player.parentNode !== document.body)
document.body.appendChild($player)
if (!active())
return
art.state = 'fullscreenWeb'
if (!active())
return
$player.style.width = '100%'
$player.style.height = '100%'
$player.classList.add('art-fullscreen-web')
}
catch (error) {
if (active() && restore(snapshot, active))
saved = undefined
throw error
}
if (!active())
return
art.emit('fullscreenWeb', true)
}
else {
if (saved) {
if (!restore(saved, active))
return
saved = undefined
}
else {
$player.classList.remove('art-fullscreen-web')
}
if (!active())
return
art.emit('fullscreenWeb', false)
}
if (active())
art.emit('resize')
}
}
@@ -0,0 +1,107 @@
import type { PipHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
type Video = PipHost['template']['$video']
const abandoned = new WeakSet<Video>()
const active = (video: Video) => video.webkitPresentationMode === 'picture-in-picture'
function clearAbandoned(video: Video) {
abandoned.delete(video)
video.removeEventListener('webkitpresentationmodechanged', releaseAbandoned)
video.removeEventListener('enterpictureinpicture', releaseAbandoned)
}
function releaseAbandoned(this: Video) {
if (!abandoned.has(this) || !active(this))
return
clearAbandoned(this)
try {
this.webkitSetPresentationMode?.('inline')
}
catch {}
}
function abandon(video: Video) {
abandoned.add(video)
video.addEventListener('webkitpresentationmodechanged', releaseAbandoned)
video.addEventListener('enterpictureinpicture', releaseAbandoned)
}
export function webkitPip(art: PipHost): PropertyDescriptor {
const { $video } = art.template
const scope = getScope(art).child()
let last = false
let pending = false
let revision = 0
let emissions = 0
$video.webkitSetPresentationMode!('inline')
function changed(force = false) {
if (isClosing(art) || abandoned.has($video))
return
const value = active($video)
if (value)
pending = false
if (value === last && !force)
return
const current = revision
if (value)
art.state = 'pip'
if (isClosing(art) || revision !== current || active($video) !== value)
return
last = value
emissions++
art.emit('pip', value)
}
scope.add(() => {
revision++
if (pending)
abandon($video)
if (active($video))
$video.webkitSetPresentationMode!('inline')
})
for (const name of ['webkitpresentationmodechanged', 'enterpictureinpicture', 'leavepictureinpicture'])
listen(scope, $video, name, () => changed())
return {
get: () => !isClosing(art) && active($video),
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
const before = emissions
if (value) {
if ($video.webkitSupportsPresentationMode?.('picture-in-picture') === false) {
art.notice.show = art.i18n.get('PIP Not Supported')
return
}
clearAbandoned($video)
art.state = 'pip'
if (isClosing(art) || revision !== current)
return
pending = true
try {
$video.webkitSetPresentationMode!('picture-in-picture')
}
catch (error) {
if (current === revision)
pending = false
throw error
}
}
else {
if (pending) {
pending = false
abandon($video)
}
if (active($video))
$video.webkitSetPresentationMode!('inline')
}
if (!isClosing(art) && current === revision && before === emissions && active($video) === Boolean(value))
changed(true)
},
}
}
@@ -1,47 +0,0 @@
import { def, setStyle } from '../utils'
export default function aspectRatioMix(art) {
const {
i18n,
notice,
template: { $video, $player },
} = art
def(art, 'aspectRatio', {
get() {
return $player.dataset.aspectRatio || 'default'
},
set(ratio) {
if (!ratio)
ratio = 'default'
if (ratio === 'default') {
setStyle($video, 'width', null)
setStyle($video, 'height', null)
setStyle($video, 'margin', null)
delete $player.dataset.aspectRatio
}
else {
const ratioArray = ratio.split(':').map(Number)
const { clientWidth, clientHeight } = $player
const playerRatio = clientWidth / clientHeight
const setupRatio = ratioArray[0] / ratioArray[1]
if (playerRatio > setupRatio) {
setStyle($video, 'width', `${setupRatio * clientHeight}px`)
setStyle($video, 'height', '100%')
setStyle($video, 'margin', '0 auto')
}
else {
setStyle($video, 'width', '100%')
setStyle($video, 'height', `${clientWidth / setupRatio}px`)
setStyle($video, 'margin', 'auto 0')
}
$player.dataset.aspectRatio = ratio
}
notice.show = `${i18n.get('Aspect Ratio')}: ${ratio === 'default' ? i18n.get('Default') : ratio}`
art.emit('aspectRatio', ratio)
},
})
}
@@ -0,0 +1,44 @@
import type { AspectRatioHost } from '../display/sizing-types'
import { containSize } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function aspectRatioMix(art: AspectRatioHost): void {
const { i18n, notice, template: { $video, $player } } = art
def(art, 'aspectRatio', {
get: () => $player.dataset.aspectRatio || 'default',
set(ratio: string) {
if (isClosing(art))
return
if (!ratio)
ratio = 'default'
if (ratio === 'default') {
$video.style.width = ''
$video.style.height = ''
$video.style.margin = ''
delete $player.dataset.aspectRatio
}
else {
// Keep the legacy split/Number coercion and dataset even for invalid ratios.
const parts = ratio.split(':').map(Number)
const size = containSize({ width: $player.clientWidth, height: $player.clientHeight }, parts[0]! / parts[1]!)
if (isClosing(art))
return
if (size?.axis === 'width') {
$video.style.width = `${size.width}px`
$video.style.height = '100%'
$video.style.margin = '0 auto'
}
else if (size) {
$video.style.width = '100%'
$video.style.height = `${size.height}px`
$video.style.margin = 'auto 0'
}
$player.dataset.aspectRatio = ratio
}
notice.show = `${i18n.get('Aspect Ratio')}: ${ratio === 'default' ? i18n.get('Default') : ratio}`
if (!isClosing(art))
art.emit('aspectRatio', ratio)
},
})
}
@@ -1,17 +0,0 @@
import { def, setStyle } from '../utils'
export default function autoHeightMix(art) {
const {
template: { $container, $video },
} = art
def(art, 'autoHeight', {
value() {
const { clientWidth } = $container
const { videoHeight, videoWidth } = $video
const height = videoHeight * (clientWidth / videoWidth)
setStyle($container, 'height', `${height}px`)
art.emit('autoHeight', height)
},
})
}
@@ -0,0 +1,19 @@
import type { AutoHeightHost } from '../display/sizing-types'
import { proportionalHeight } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function autoHeightMix(art: AutoHeightHost): void {
const { $container, $video } = art.template
def(art, 'autoHeight', {
value() {
if (isClosing(art))
return
const height = proportionalHeight($container.clientWidth, { width: $video.videoWidth, height: $video.videoHeight })
if (height === undefined || isClosing(art))
return
$container.style.height = `${height}px`
art.emit('autoHeight', height)
},
})
}
@@ -1,28 +0,0 @@
import { def, getRect, setStyle } from '../utils'
export default function autoSizeMix(art) {
const { $container, $player, $video } = art.template
def(art, 'autoSize', {
value() {
const { videoWidth, videoHeight } = $video
const { width, height } = getRect($container)
const videoRatio = videoWidth / videoHeight
const containerRatio = width / height
if (containerRatio > videoRatio) {
const percentage = ((height * videoRatio) / width) * 100
setStyle($player, 'width', `${percentage}%`)
setStyle($player, 'height', '100%')
}
else {
const percentage = (width / videoRatio / height) * 100
setStyle($player, 'width', '100%')
setStyle($player, 'height', `${percentage}%`)
}
art.emit('autoSize', {
width: art.width,
height: art.height,
})
},
})
}
@@ -0,0 +1,30 @@
import type { AutoSizeHost } from '../display/sizing-types'
import { containSize, positiveSize } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def, getRect } from '../utils'
export default function autoSizeMix(art: AutoSizeHost): void {
const { $container, $player, $video } = art.template
def(art, 'autoSize', {
value() {
if (isClosing(art))
return
const media = { width: $video.videoWidth, height: $video.videoHeight }
if (!positiveSize(media))
return
const container = getRect($container)
const size = containSize(container, media.width / media.height)
if (!size || isClosing(art))
return
if (size.axis === 'width') {
$player.style.width = `${(size.width / container.width) * 100}%`
$player.style.height = '100%'
}
else {
$player.style.width = '100%'
$player.style.height = `${(size.height / container.height) * 100}%`
}
art.emit('autoSize', { width: art.width, height: art.height })
},
})
}
-28
View File
@@ -1,28 +0,0 @@
import { capitalize, def } from '../utils'
export default function flipMix(art) {
const {
template: { $player },
i18n,
notice,
} = art
def(art, 'flip', {
get() {
return $player.dataset.flip || 'normal'
},
set(flip) {
if (!flip)
flip = 'normal'
if (flip === 'normal') {
delete $player.dataset.flip
}
else {
$player.dataset.flip = flip
}
notice.show = `${i18n.get('Video Flip')}: ${i18n.get(capitalize(flip))}`
art.emit('flip', flip)
},
})
}
+23
View File
@@ -0,0 +1,23 @@
import type { FlipHost } from '../display/sizing-types'
import { isClosing } from '../lifecycle/instance'
import { capitalize, def } from '../utils'
export default function flipMix(art: FlipHost): void {
const { template: { $player }, i18n, notice } = art
def(art, 'flip', {
get: () => $player.dataset.flip || 'normal',
set(flip: string) {
if (isClosing(art))
return
if (!flip)
flip = 'normal'
if (flip === 'normal')
delete $player.dataset.flip
else
$player.dataset.flip = flip
notice.show = `${i18n.get('Video Flip')}: ${i18n.get(capitalize(flip))}`
if (!isClosing(art))
art.emit('flip', flip)
},
})
}
@@ -1,88 +0,0 @@
import screenfull from '../libs/screenfull'
import { addClass, def, get, removeClass } from '../utils'
export default function fullscreenMix(art) {
const {
i18n,
notice,
template: { $video, $player },
} = art
const nativeScreenfull = (art) => {
screenfull.on('change', () => {
art.emit('fullscreen', screenfull.isFullscreen)
if (screenfull.isFullscreen) {
art.state = 'fullscreen'
addClass($player, 'art-fullscreen')
}
else {
removeClass($player, 'art-fullscreen')
}
art.emit('resize')
})
screenfull.on('error', (event) => {
art.emit('fullscreenError', event)
})
def(art, 'fullscreen', {
get() {
return screenfull.isFullscreen
},
async set(value) {
if (value) {
await screenfull.request($player)
}
else {
await screenfull.exit()
}
},
})
}
const webkitScreenfull = (art) => {
art.on('document:webkitfullscreenchange', () => {
art.emit('fullscreen', art.fullscreen)
art.emit('resize')
})
def(art, 'fullscreen', {
get() {
return document.fullscreenElement === $video
},
set(value) {
if (value) {
art.state = 'fullscreen'
$video.webkitEnterFullscreen()
}
else {
$video.webkitExitFullscreen()
}
},
})
}
art.once('video:loadedmetadata', () => {
if (screenfull.isEnabled) {
nativeScreenfull(art)
}
else if ($video.webkitSupportsFullscreen) {
webkitScreenfull(art)
}
else {
def(art, 'fullscreen', {
get() {
return false
},
set() {
notice.show = i18n.get('Fullscreen Not Supported')
},
})
}
// Asynchronous setting
def(art, 'fullscreen', get(art, 'fullscreen'))
})
}
@@ -0,0 +1,35 @@
import type { FullscreenHost } from '../display/types'
import { fullscreenAdapter } from '../display/fullscreen-adapter'
import { nativeFullscreen } from '../display/native-fullscreen'
import { videoFullscreen } from '../display/video-fullscreen'
import { getScope, isClosing } from '../lifecycle/instance'
import { def, get } from '../utils'
export default function fullscreenMix(art: FullscreenHost): void {
const { i18n, notice, template: { $video, $player } } = art
const metadata = () => {
if (isClosing(art))
return
const adapter = fullscreenAdapter($player)
if (adapter) {
def(art, 'fullscreen', nativeFullscreen(art, adapter))
}
else if ($video.webkitSupportsFullscreen && typeof $video.webkitEnterFullscreen === 'function' && typeof $video.webkitExitFullscreen === 'function') {
def(art, 'fullscreen', videoFullscreen(art))
}
else {
def(art, 'fullscreen', {
get: () => false,
set() {
if (!isClosing(art))
notice.show = i18n.get('Fullscreen Not Supported')
},
})
}
def(art, 'fullscreen', get(art, 'fullscreen')!)
}
art.once('video:loadedmetadata', metadata)
getScope(art).add(() => {
art.off('video:loadedmetadata', metadata)
})
}
@@ -1,41 +0,0 @@
import { addClass, append, def, hasClass, removeClass, setStyle } from '../utils'
export default function fullscreenWebMix(art) {
const {
constructor,
template: { $container, $player },
} = art
let cssText = ''
def(art, 'fullscreenWeb', {
get() {
return hasClass($player, 'art-fullscreen-web')
},
set(value) {
if (value) {
cssText = $player.style.cssText
if (constructor.FULLSCREEN_WEB_IN_BODY) {
append(document.body, $player)
}
art.state = 'fullscreenWeb'
setStyle($player, 'width', '100%')
setStyle($player, 'height', '100%')
addClass($player, 'art-fullscreen-web')
art.emit('fullscreenWeb', true)
}
else {
if (constructor.FULLSCREEN_WEB_IN_BODY) {
append($container, $player)
}
if (cssText) {
$player.style.cssText = cssText
cssText = ''
}
removeClass($player, 'art-fullscreen-web')
art.emit('fullscreenWeb', false)
}
art.emit('resize')
},
})
}
@@ -0,0 +1,13 @@
import type { WebFullscreenHost, WebFullscreenProperty } from '../display/types'
import { webFullscreen } from '../display/web-fullscreen'
import { def } from '../utils'
export default function fullscreenWebMix(art: WebFullscreenHost): asserts art is WebFullscreenHost & WebFullscreenProperty {
const setFullscreen = webFullscreen(art)
def(art, 'fullscreenWeb', {
get(): boolean {
return art.template.$player.classList.contains('art-fullscreen-web')
},
set: setFullscreen,
})
}
-134
View File
@@ -1,134 +0,0 @@
import { addClass, append, createElement, def, getRect, hasClass, isInViewport, removeClass, setStyle, silencePromise } from '../utils'
export default function miniMix(art) {
const {
icons,
proxy,
storage,
template: { $player, $video },
} = art
let isDragging = false
let lastPageX = 0
let lastPageY = 0
function hideMini() {
const { $mini } = art.template
if ($mini) {
removeClass($player, 'art-mini')
setStyle($mini, 'display', 'none')
$player.prepend($video)
art.emit('mini', false)
}
}
function initState($play, $pause) {
if (art.playing) {
setStyle($play, 'display', `none`)
setStyle($pause, 'display', `flex`)
}
else {
setStyle($play, 'display', `flex`)
setStyle($pause, 'display', `none`)
}
}
function createMini() {
const { $mini } = art.template
if ($mini) {
append($mini, $video)
return setStyle($mini, 'display', 'flex')
}
else {
const $mini = createElement('div')
addClass($mini, 'art-mini-popup')
append(document.body, $mini)
art.template.$mini = $mini
append($mini, $video)
const $close = append($mini, `<div class="art-mini-close"></div>`)
append($close, icons.close)
proxy($close, 'click', hideMini)
const $state = append($mini, `<div class="art-mini-state"></div>`)
const $play = append($state, icons.play)
const $pause = append($state, icons.pause)
proxy($play, 'click', () => silencePromise(art.play()))
proxy($pause, 'click', () => art.pause())
initState($play, $pause)
art.on('video:playing', () => initState($play, $pause))
art.on('video:pause', () => initState($play, $pause))
art.on('video:timeupdate', () => initState($play, $pause))
proxy($mini, 'mousedown', (event) => {
isDragging = event.button === 0
lastPageX = event.pageX
lastPageY = event.pageY
})
art.on('document:mousemove', (event) => {
if (isDragging) {
addClass($mini, 'art-mini-dragging')
const x = event.pageX - lastPageX
const y = event.pageY - lastPageY
setStyle($mini, 'transform', `translate(${x}px, ${y}px)`)
}
})
art.on('document:mouseup', () => {
if (isDragging) {
isDragging = false
removeClass($mini, 'art-mini-dragging')
const rect = getRect($mini)
storage.set('left', rect.left)
storage.set('top', rect.top)
setStyle($mini, 'left', `${rect.left}px`)
setStyle($mini, 'top', `${rect.top}px`)
setStyle($mini, 'transform', null)
}
})
return $mini
}
}
function initMini() {
const { $mini } = art.template
const rect = getRect($mini)
const top = window.innerHeight - rect.height - 50
const left = window.innerWidth - rect.width - 50
storage.set('top', top)
storage.set('left', left)
setStyle($mini, 'top', `${top}px`)
setStyle($mini, 'left', `${left}px`)
}
def(art, 'mini', {
get() {
return hasClass($player, 'art-mini')
},
set(value) {
if (value) {
art.state = 'mini'
addClass($player, 'art-mini')
const $mini = createMini()
const top = storage.get('top')
const left = storage.get('left')
if (typeof top === 'number' && typeof left === 'number') {
setStyle($mini, 'top', `${top}px`)
setStyle($mini, 'left', `${left}px`)
if (!isInViewport($mini)) {
initMini()
}
}
else {
initMini()
}
art.emit('mini', true)
}
else {
hideMini()
}
},
})
}
+7
View File
@@ -0,0 +1,7 @@
import type { MiniHost, MiniProperty } from '../display/types'
import { mini } from '../display/mini'
import { def } from '../utils'
export default function miniMix(art: MiniHost): asserts art is MiniHost & MiniProperty {
def(art, 'mini', mini(art))
}
-87
View File
@@ -1,87 +0,0 @@
import { def } from '../utils'
function nativePip(art) {
const {
template: { $video },
proxy,
notice,
} = art
$video.disablePictureInPicture = false
def(art, 'pip', {
get() {
return document.pictureInPictureElement
},
set(value) {
if (value) {
art.state = 'pip'
$video.requestPictureInPicture().catch((err) => {
notice.show = err
throw err
})
}
else {
document.exitPictureInPicture().catch((err) => {
notice.show = err
throw err
})
}
},
})
proxy($video, 'enterpictureinpicture', () => {
art.emit('pip', true)
})
proxy($video, 'leavepictureinpicture', () => {
art.emit('pip', false)
})
}
function webkitPip(art) {
const { $video } = art.template
$video.webkitSetPresentationMode('inline')
def(art, 'pip', {
get() {
return $video.webkitPresentationMode === 'picture-in-picture'
},
set(value) {
if (value) {
art.state = 'pip'
$video.webkitSetPresentationMode('picture-in-picture')
art.emit('pip', true)
}
else {
$video.webkitSetPresentationMode('inline')
art.emit('pip', false)
}
},
})
}
export default function pipMix(art) {
const {
i18n,
notice,
template: { $video },
} = art
if (document.pictureInPictureEnabled) {
nativePip(art)
}
else if ($video.webkitSupportsPresentationMode) {
webkitPip(art)
}
else {
def(art, 'pip', {
get() {
return false
},
set() {
notice.show = i18n.get('PIP Not Supported')
},
})
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { PipHost, PipProperty } from '../display/types'
import { nativePip } from '../display/native-pip'
import { webkitPip } from '../display/webkit-pip'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function pipMix(art: PipHost): asserts art is PipHost & PipProperty {
const { i18n, notice, template: { $video } } = art
const document = $video.ownerDocument
if (document.pictureInPictureEnabled && typeof $video.requestPictureInPicture === 'function' && typeof document.exitPictureInPicture === 'function') {
def(art, 'pip', nativePip(art))
}
else if (typeof $video.webkitSupportsPresentationMode === 'function' && typeof $video.webkitSetPresentationMode === 'function') {
def(art, 'pip', webkitPip(art))
}
else {
def(art, 'pip', {
get: () => false,
set() {
if (!isClosing(art))
notice.show = i18n.get('PIP Not Supported')
},
})
}
}
@@ -1,101 +0,0 @@
import { addClass, getSafeAreaInsets, hasClass, removeClass, setStyle } from '../utils'
export default function autoOrientation(art) {
const {
notice,
constructor,
template: { $player, $video },
} = art
const WEB_CLASS = 'art-auto-orientation'
const FS_CLASS = 'art-auto-orientation-fullscreen'
let fsLocked = false
function applyWebRotate() {
const insets = getSafeAreaInsets()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
const safeWidth = viewHeight - insets.top - insets.bottom
const safeHeight = viewWidth - insets.left - insets.right
setStyle($player, 'width', `${safeWidth}px`)
setStyle($player, 'height', `${safeHeight}px`)
setStyle($player, 'transform-origin', '0 0')
setStyle($player, 'transform', `rotate(90deg) translate(${insets.top}px, -${viewWidth - insets.right}px)`)
addClass($player, WEB_CLASS)
art.isRotate = true
art.emit('resize')
}
function clearWebRotate() {
setStyle($player, 'width', '')
setStyle($player, 'height', '')
setStyle($player, 'transform-origin', '')
setStyle($player, 'transform', '')
removeClass($player, WEB_CLASS)
art.isRotate = false
art.emit('resize')
}
function needRotate() {
const { videoWidth, videoHeight } = $video
const vw = document.documentElement.clientWidth
const vh = document.documentElement.clientHeight
return (videoWidth > videoHeight && vw < vh) || (videoWidth < videoHeight && vw > vh)
}
art.on('fullscreenWeb', (state) => {
if (state) {
if (needRotate()) {
const delay = Number(constructor.AUTO_ORIENTATION_TIME ?? 0)
setTimeout(() => {
if (art.fullscreenWeb && !hasClass($player, WEB_CLASS)) {
applyWebRotate()
}
}, delay)
}
}
else {
if (hasClass($player, WEB_CLASS))
clearWebRotate()
}
})
art.on('fullscreen', async (state) => {
const canLock = !!screen?.orientation?.lock
if (state) {
if (canLock && needRotate()) {
try {
const last = screen.orientation.type
const opposite = last.startsWith('portrait') ? 'landscape' : 'portrait'
await screen.orientation.lock(opposite)
fsLocked = true
addClass($player, FS_CLASS)
}
catch (err) {
fsLocked = false
notice.show = err
}
}
}
else {
if (hasClass($player, FS_CLASS)) {
removeClass($player, FS_CLASS)
}
if (canLock && fsLocked) {
try {
screen.orientation.unlock()
}
catch { }
fsLocked = false
}
}
})
return {
name: 'autoOrientation',
get state() {
return hasClass($player, WEB_CLASS)
},
}
}
@@ -0,0 +1,30 @@
import type { OrientationHost } from '../display/orientation-types'
import { nativeOrientation } from '../display/orientation-native'
import { webOrientation } from '../display/orientation-web'
import { positiveSize } from '../display/sizing'
import { getScope } from '../lifecycle/instance'
export default function autoOrientation(art: OrientationHost): { name: string, readonly state: boolean } {
const { $player, $video } = art.template
const needRotate = () => {
const media = { width: $video.videoWidth, height: $video.videoHeight }
const viewport = $player.ownerDocument.documentElement
const view = { width: viewport.clientWidth, height: viewport.clientHeight }
return positiveSize(media) && positiveSize(view)
&& ((media.width > media.height && view.width < view.height) || (media.width < media.height && view.width > view.height))
}
const web = webOrientation(art, needRotate)
const native = nativeOrientation(art, needRotate)
art.on('fullscreenWeb', web)
art.on('fullscreen', native)
getScope(art).add(() => {
art.off('fullscreenWeb', web)
art.off('fullscreen', native)
})
return {
name: 'autoOrientation',
get state() {
return $player.classList.contains('art-auto-orientation')
},
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
import type { BuiltinRegistry, PluginHost } from './types'
import type { BuiltinRegistry, PluginFactory, PluginHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import autoOrientation from './autoOrientation'
@@ -15,8 +15,10 @@ export default function installBuiltins<Host extends PluginHost<Host>>(registry:
registry.add(lock)
if (!isClosing(registry.art) && option.autoPlayback && !option.isLive)
registry.add(autoPlayback)
if (!isClosing(registry.art) && option.autoOrientation && isMobile)
registry.add(autoOrientation)
if (!isClosing(registry.art) && option.autoOrientation && isMobile) {
// The JS constructor supplies the complete host before builtin installation.
registry.add(autoOrientation as unknown as PluginFactory<Host>)
}
if (!isClosing(registry.art) && option.fastForward && isMobile && !option.isLive)
registry.add(fastForward)
}
+2
View File
@@ -3,6 +3,8 @@
z-index: 9999;
width: 320px;
height: 180px;
max-width: 100vw;
max-height: 100vh;
background: #000;
border-radius: var(--art-border-radius);
cursor: move;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,728 @@
{
"task": "CORE-16",
"date": "2026-09-11",
"status": "partial",
"phase": "native-document-fullscreen",
"parentCommit": "1b83081e8ffa893a17b14414c7698bfed7113b1a",
"source": {
"packages/artplayer/src/component/dom.ts": "740f63e6e5942c444b9f05605880f8d56a77b066dbe5c4791da74f78cb96c668",
"packages/artplayer/src/component/resources.ts": "ff158ef0db8ba13343ff6ba329713820cfe2f5c2c446a7873a6cfff782855edf",
"packages/artplayer/src/component/selection.ts": "1f0bd089415da28db862647ffd1a0b4af752576465fb616d4b97ae4f8dcae3a7",
"packages/artplayer/src/component/types.ts": "d7b0e7008b0f6c82e3e97ac43e0188598dca4f740a860e7a60b3b38a46f4565d",
"packages/artplayer/src/config/index.js": "d6d2bd612daaf6c5c6e982158acbdf724b16d3c35bb5f1a5c923284665a8de79",
"packages/artplayer/src/contextmenu/aspectRatio.ts": "a25464e14216b2be392fcb3fdda2439e6d6f98d1962f0acbba30e3bd53bcb2d4",
"packages/artplayer/src/contextmenu/close.ts": "84b7a96df910e6f38140df8b1e73519d209a7331e9be9f42d076ae791757024f",
"packages/artplayer/src/contextmenu/flip.ts": "42c192d23e347d74e0bb814697a4d59eac20afbec102a282d5aa15780cab5f72",
"packages/artplayer/src/contextmenu/index.ts": "4fd292a931b5bda9f16a1c2bedd50cd0d8c0b8fb6010ffc5faaad2c197d2d9e5",
"packages/artplayer/src/contextmenu/info.ts": "f37cf0104510c13a130d2f55114c8f7ff15cc1b51863f97e60825442bdb2bf01",
"packages/artplayer/src/contextmenu/playbackRate.ts": "e177f4017ea9291794fb4023715c81bfc01f927324ab9bd0e32c741e4a3b0ca7",
"packages/artplayer/src/contextmenu/types.ts": "a93fc90f4e17b731e8f6b486a507117810b61c1f1b0d9e775eba27dabeaeee1d",
"packages/artplayer/src/contextmenu/version.ts": "0553a716be0ba49f614059c41eef6dc694a9641fca79e6448b1b30382c6b582b",
"packages/artplayer/src/control/airplay.ts": "a02ab41654a963e06fb2eb206cfe606cebc27252ba00b3fc2116ea232c8d7d51",
"packages/artplayer/src/control/builtins.ts": "2a0e12a9741713c92938e1d2009149bbbde498284aa85c63f270d02b27dc22b1",
"packages/artplayer/src/control/fullscreen.ts": "6856d98530877b9458275d8e064a94a81b9e2a4de079cce1a83e7cbe71e5ab8b",
"packages/artplayer/src/control/fullscreenWeb.ts": "e20f8fe9788738adfccb21e7bf248e5e8068312e856bb2626d05fbb20cfdb65d",
"packages/artplayer/src/control/index.ts": "ce23b79a0a40f22bdd8ad5f461d6b0df9366c7e4ab78571e0c67649f6e6d38e7",
"packages/artplayer/src/control/layout.ts": "b5134a9d0cb5ad128b1fb229dce79a306b944f750d8620e224a99f5791e6cab5",
"packages/artplayer/src/control/pip.ts": "9299d785ce21b16dc9edbcb6a803f8748157ed16e1924f461bdbc4853adaa5ac",
"packages/artplayer/src/control/playAndPause.ts": "e65461f5bf29680f12b7e0efa7d44186f07bbe9936247a9a220f724c9d9774bb",
"packages/artplayer/src/control/progress/interactions.ts": "19d6f73c21f75daaef109247bee392d82b46fecffd04be0944386c307496b250",
"packages/artplayer/src/control/progress/position.ts": "1008c5129097cd2c51fefb56e88e9df6fa07f09ed5f4492505da9153d8284cd1",
"packages/artplayer/src/control/progress/view.ts": "60ee16d5170be81b8132bb56dd0bc0eaf23e50f5c268d0697c9187b38a14aba9",
"packages/artplayer/src/control/progress.ts": "f0876ddbdc01f812a5a776bdeefdff8dc52e557aff684f99b5ea7f6bfc8c0fb3",
"packages/artplayer/src/control/resources.ts": "16fd7c53ac8f47b333041c4f298313390dc102523d47767b8a16f39a2564ba74",
"packages/artplayer/src/control/screenshot.ts": "430dc1b0283bc8a5c5396539b291dce1197470611696b32fde4b2c56d2db6b1f",
"packages/artplayer/src/control/selector.ts": "5d946c36d0308f32e08dd0e189f0c959f738ed7ab521230833c76a5bf9ca6478",
"packages/artplayer/src/control/setting.ts": "63e9824ffee2221ba82be9f2bc430b9b3133a2186905fcce2a61b8aa946a3d8a",
"packages/artplayer/src/control/time.ts": "fa572092e0848878689367fb536e9658d13888d0a0d476babc5acd165af0f8d3",
"packages/artplayer/src/control/types.ts": "7fab47c7106efa51aae89026af335f74d9f3ea52ef9864ec2801d0ea2deffc4c",
"packages/artplayer/src/control/volume.ts": "b50d1a5d2697d6440ac0f3d82634b13f7c2fff00c185a1adf4451ed8ba810709",
"packages/artplayer/src/display/fullscreen-abandoned.ts": "1715b30d336976921b4957b444418702b7d93dbf1debce47429269638d15810f",
"packages/artplayer/src/display/fullscreen-adapter.ts": "71da10ec21c7706a8264badedac9022c74b865887931e9e369168fe3233c3b2f",
"packages/artplayer/src/display/fullscreen-request.ts": "67015be4f49e8df280ad5666a2242c8fcf13ddff64e3c0c47fd2d67f53e486b1",
"packages/artplayer/src/display/native-fullscreen.ts": "be4911938a4cc708b96043550c03176188a6272fad2444921555801268535c5c",
"packages/artplayer/src/display/placement.ts": "dbe4491ebd0f46b4efe13ac567e476fcd9ca5b84da675f19025935b5e3f8d733",
"packages/artplayer/src/display/types.ts": "f6467708487b7999d4ee1c2ec511c7d49d972b05943ef11c23f30fdeac868366",
"packages/artplayer/src/display/web-fullscreen.ts": "ef3a29a058133395e5b1eb36ffb742290ff30cffa3f22ac690a9283a8a566678",
"packages/artplayer/src/events/clickInit.js": "bc680c921a958c25705962577513677d5d97de94e4eff6a04ba20d2f60e6a1be",
"packages/artplayer/src/events/gestureInit.js": "a5dcdb4216c8e3544fc85a1b4d3ebd929ec6f7d51ab17f2cee713030ae7ad464",
"packages/artplayer/src/events/globalInit.js": "5d8c3147f17755b0d35504cfc69cf8cbff510b66b2a52686d8f2a235a6c5b9df",
"packages/artplayer/src/events/hoverInit.js": "7ed5666e01972dc0955d1c1b4006001c99dd55ed133ca026f685b71d6b4d01c6",
"packages/artplayer/src/events/index.js": "94ddb9ad5ceb21901fbbc974d983fec60c488314474c89138e4ab2b40e205d5a",
"packages/artplayer/src/events/moveInit.js": "782d627260c9fdf46c0766876138f05836860bc9c2fa2c519d22b43f3d24de15",
"packages/artplayer/src/events/resizeInit.js": "26fc9628490e6c45ef741e7bc7c1353097b21da1dc83aae65dc80783226e36bc",
"packages/artplayer/src/events/updateInit.js": "1b4ef85c819ae68eb0de1791241b2c3d4e22bb108746518fa67f7c322591e55a",
"packages/artplayer/src/events/viewInit.js": "2b4ce508812b8f03cf5801a5e3a7eaa4a5a8b05d6be500d217aeb6231fb93a51",
"packages/artplayer/src/hotkey.js": "689fb87b2ed8a424cbc2880c8f70610b3695dc5b0346c9a5833699996682c0d6",
"packages/artplayer/src/i18n/ar.ts": "c1e41787c700096ecbc91de6558353f43d8930929ee312b7aa2c7da14a07ec75",
"packages/artplayer/src/i18n/cs.ts": "62c5ffcf3ac24e0dc2d7222b7ebb2b4d3454e9c04b80930a50defb3867d645be",
"packages/artplayer/src/i18n/es.ts": "5d31184b41ef29c1544c91ff08e3bbf9fc5c5776f0af0aa0238f90a9d161b9b1",
"packages/artplayer/src/i18n/fa.ts": "1294b91178ebdc7bc26d14a6f407ba8598207ae0dc812f3b821b62b7bf0089e5",
"packages/artplayer/src/i18n/fr.ts": "7125c0011b90f507d5b5fe20afd875992b62af9cdd44f02bbabed051c4ebc107",
"packages/artplayer/src/i18n/id.ts": "0d2e79cc0118cff6be83786a3fc048461785a64bdffc6bc45ffb68cb595f6da4",
"packages/artplayer/src/i18n/index.ts": "09c6f5bce58e4118dde10e8b2a2d3808785b7fde913a54dd189e0b473e21266a",
"packages/artplayer/src/i18n/pl.ts": "f6fb58a0d3fa118e22cfa6df5edd74c7bf39dc9ff1d416fb2b01e1bb8708b06f",
"packages/artplayer/src/i18n/publish.ts": "5b216bbe313b3cda56a5da41fba4fb9d095eeec0dc903fcf284ddd8f94926e99",
"packages/artplayer/src/i18n/ru.ts": "473c43197acec10991b0f67814f79421dfe9ec4f534fb15413a32cf2a4d8ad38",
"packages/artplayer/src/i18n/tr.ts": "7b6afbbe452c4907c8bef36663bd218fd140bd1a4b3c776f31c53415e78e8f16",
"packages/artplayer/src/i18n/vi.ts": "29ea7f23d7400c847596632c86a4ca10c39cbafe1e6d723ed099b819aca7e764",
"packages/artplayer/src/i18n/zh-cn.ts": "22584521b0aa92da5278b01581e6e8e5bb3a23a2c3029e9df8642b7f0bded392",
"packages/artplayer/src/i18n/zh-tw.ts": "8da94a4fd508176bbb6b7ce2f4ba6ef6a56e7b1229bc67cc42d1a7bde70a7e9d",
"packages/artplayer/src/icons/airplay.svg": "8c75a40f1bfacee2be2a425e132ff51f3712eba60679010f7b1349d4a04374bc",
"packages/artplayer/src/icons/arrow-left.svg": "8b9d544b7bd728d01747005fc7a2d63f822eed3b605393e2435b6733861b585f",
"packages/artplayer/src/icons/arrow-right.svg": "68c63d6d5810173ec9192c9e96ee7b6aefdc3b79d3c1329fb4d6c750f859fe10",
"packages/artplayer/src/icons/aspect-ratio.svg": "987752a87701523057d8dfa7c45a6a139c4542b96fdcfaf8665722bbeb41f3b2",
"packages/artplayer/src/icons/check.svg": "963ce0aee2337fdee951aa6c917cc75ee0b4e75158887ccc16baebfd3925942b",
"packages/artplayer/src/icons/close.svg": "ebabfe8044f48d5df4d13f4f906a163fd55a4d6f1dedb3b5ba95601209bc8888",
"packages/artplayer/src/icons/config.svg": "e1884d5411296b5cd1bfec7803b558b0afa5ab64478393977b07a662e2e0f15c",
"packages/artplayer/src/icons/defaults.ts": "31df862d754922fe5d68b20911c477d7c22893a6a67b5dcb1a48d3b437a0b23a",
"packages/artplayer/src/icons/error.svg": "b40c01e511a599c9dd369aeb9a3b2a0d1a76ef3c5ede8124cbbc23f8e4e7248c",
"packages/artplayer/src/icons/fast-forward.svg": "abef6fe4d75ffa74d3a84683373b4702a4eda2a5b4045952f969ee62cc68b0be",
"packages/artplayer/src/icons/flip.svg": "474a9d53a97add71e3fb7bc6530a68580ce63a1d83f9b75d1c2908a7cf494152",
"packages/artplayer/src/icons/fullscreen-off.svg": "71125ac56586897e2c0e52035a4d65cff34cf52043a95b45d186e0d268671399",
"packages/artplayer/src/icons/fullscreen-on.svg": "d46cdf265cac729c9b7382722ebe7a4c76d71ad4e5a27c2d5ed8323fd595ebf5",
"packages/artplayer/src/icons/fullscreen-web-off.svg": "52a6862ecd7cba12ab7042de5064c498bc2e9f86b430358c912401fafe4f4a98",
"packages/artplayer/src/icons/fullscreen-web-on.svg": "5cfaae62b61456251e771bff01356fad8376ebedb10a4b8819740f60e6e5a073",
"packages/artplayer/src/icons/index.ts": "3ef720aee403e6b7baf1ab472dbdd16c671f2d691ecea48ff1c25ca1ffc66296",
"packages/artplayer/src/icons/loading.svg": "485472153890eff3ed49963f7bf7d841f66ca2f40894c01d6e8bcf1bf4ad1c58",
"packages/artplayer/src/icons/lock.svg": "431e9a25fae051016be0efa286387ec3e5f7651c3ecf569bf0ece9842f57156c",
"packages/artplayer/src/icons/pause.svg": "57405dba81dd3d9f329d957fdc704101cbb6f6654325eb0083c03324c4453a68",
"packages/artplayer/src/icons/pip.svg": "1e922140e0941e5b9ef53dc0b4776813e1c470ea229fe3ec786fd148148c3352",
"packages/artplayer/src/icons/play.svg": "41a961c629a55cb25a813e115a25204e492d4e935a0879177e65d6e1ddfd6796",
"packages/artplayer/src/icons/playback-rate.svg": "872deecac0365ab04aeb5ccb1cdd9a0c88589a1442709fdac7c0ed12a241ff2b",
"packages/artplayer/src/icons/screenshot.svg": "538b5cd0c6b20afb98bd850e15019072d652d191075c2869f2fc0f28b597b131",
"packages/artplayer/src/icons/setting.svg": "2976b00d1f03e78f28d0e55ebcb01d5a0746ec1a6ff8de97a5e8294bacdd1fad",
"packages/artplayer/src/icons/state.svg": "620a127f74bb5e249993bd7008c027373263eacb4a44af8adeaa0d7523346887",
"packages/artplayer/src/icons/switch-off.svg": "95a8d81a4a3bfcf7c016d5f71d46a72be73c5e94790c55fd1d31023416ef5deb",
"packages/artplayer/src/icons/switch-on.svg": "cccd0f9c1e54d9ac50c323360da944d8ff44633a82ff17889914b6bc8b1652ae",
"packages/artplayer/src/icons/unlock.svg": "fc058db3d99a475fb9ffbbbb6248978a95990a9c532b83d0955ac28eaa626c60",
"packages/artplayer/src/icons/volume-close.svg": "f7e100fd9e9f3f6e06afe5bc994d5d9305ee4d1ee23fae37e4108cf286dab379",
"packages/artplayer/src/icons/volume.svg": "3a78f40d8b48595e8f15d9189e4d767e0c7abbf0338d3f2db1ac810049dd3942",
"packages/artplayer/src/index.js": "cb874ac34fa650bd364b3fef7eed4830bf3bd47093097ae7ba0432e7e64b1a10",
"packages/artplayer/src/info.js": "6dcdb4457d63ddb74fb6268df64522722420d6c0eb7b1654d6fb7703466d3b8c",
"packages/artplayer/src/layer.ts": "4014268eed043d5dd7b925cdeef6245e357cda67192a1301c5612ef98cdbec12",
"packages/artplayer/src/libs/screenfull.js": "2332b50c0c6364d5c99080be0f5739255e4b9e465d2cca40f761adce9a1908f8",
"packages/artplayer/src/lifecycle/instance.ts": "19a89de4ae22cb57eea7302265ce07422d12d1aea5acb99deb44fd0dba351a13",
"packages/artplayer/src/lifecycle/resources.ts": "399ea5e84545c973f8967e8ffd7326dad7b3168ca6f96f77286995a5b62ee389",
"packages/artplayer/src/lifecycle/scope.ts": "e2188e8544b27cf8c7ac149e7146af628a2d6166c82b719a247adef5d85be5db",
"packages/artplayer/src/lifecycle/template-rollback.ts": "7a83da6739bcec1587b02f8157934988910d7f9103fe489a0d6f4f65290c3d77",
"packages/artplayer/src/loading.js": "4b08a8f9fa69f71d054cdf1508bbb9e92c075c8df1eda0fa8a79efbf08eb8c40",
"packages/artplayer/src/mask.js": "094162e28ec76c81f73bb7bfe276bf5832080ffb7aa9d9dc9dc013479659c2e5",
"packages/artplayer/src/media/events/forward.ts": "6b1b7e80036bfb523b51843dc6ec6d83c2b949cefb9cef085b2d679a444e46d8",
"packages/artplayer/src/media/events/listen.ts": "4eb7e943c1ebca77da8025d83b7d408276187cd02860b7793284344378bd0fe0",
"packages/artplayer/src/media/events/playback.ts": "2c68e4f6274a0dc65e56d42c928e500fc1d63bf3e03bbdd8a83c3f3ed7ae8ee4",
"packages/artplayer/src/media/events/readiness.ts": "3ba74f26dd9ff0729967097c2cb6f329a9cea57745bfe8173f9173e0d134476a",
"packages/artplayer/src/media/events/reconnect.ts": "876027b5116d0d3628755705682b30630f4b713199ed8015541932d81d2944be",
"packages/artplayer/src/media/events/types.ts": "5532f57129032a9685305d0d319543146c3e8af134ac12c8aa521cca9e83a6c2",
"packages/artplayer/src/media/hosts.ts": "7129cb93290a250e932464b71a4e4abce8a98e83935d4fd6c36a59fc6357b6af",
"packages/artplayer/src/media/playback.ts": "b0e8ae22f2660249361bf05bb2333685e14f5a7ac3e9ba0c62ee7efd38338b93",
"packages/artplayer/src/media/types.ts": "96d4a7f066cbb6fb9138165dd736b4ca500cb19c827bd7702134d61c44cc0e87",
"packages/artplayer/src/notice.js": "72e97833e0499993d8d133111e32f32f031b911426117397be2003e824023664",
"packages/artplayer/src/option/defaults.ts": "f357ef80cf32085fcbc3409d92dfa27b31c13fbcf823f576052c0347911c0cca",
"packages/artplayer/src/option/resolve.ts": "53a73627cd540e3f21349c0a43beda35ea5195647a31e3f5b30ac292390b4b1b",
"packages/artplayer/src/option/types.ts": "f2ce01c01acf45fac6cbb34685b8e6c321bcad6b0da2526f3f189eb5962ea0e5",
"packages/artplayer/src/player/airplayMix.js": "b9ce49489e75045b6a1df4d8ed28f123c1b105ac4c04fefaabcffe4353ad004d",
"packages/artplayer/src/player/aspectRatioMix.js": "c0a4cbd84bd0d70f078d87fc2ed082ee81fd33eed12b6265fa2d6f043b8f0f97",
"packages/artplayer/src/player/attrMix.js": "c873d587ff71e024cfda5b5464d0c70a09a6a319da3f18fbaa7451a16c9ae055",
"packages/artplayer/src/player/autoHeightMix.js": "e7400a46eeb01416d5ac7c6b787295cd9b9f90b38ce640a737ef5a34a8affd6d",
"packages/artplayer/src/player/autoSizeMix.js": "341e8c6a6a1b451c43f758a7774d1cd6cdf9e1f9e818692e03eb58c2fbda4525",
"packages/artplayer/src/player/cssVarMix.js": "93e641dd760e8f844a643a0b7d8af92b73a7f205e55087d9501934d218701bd2",
"packages/artplayer/src/player/currentTimeMix.ts": "78c749ec4dc94f4a3a0ce02a87156f07894401b64d12c6c7c3cb1192ff266902",
"packages/artplayer/src/player/durationMix.ts": "60a9d665cf302dcb6b3e37e2f057e31720935e5e588fd7709231f691f6014191",
"packages/artplayer/src/player/eventInit.ts": "a915c5a618e70b1ab10536b1273ba0fea4ec11d3a2ca629b538e90b5ad2b55f9",
"packages/artplayer/src/player/flipMix.js": "f5abb8d079d0a9c4f9eaa31c4940723bf407ba422ac713dc73443dfa6cb6ac4f",
"packages/artplayer/src/player/fullscreenMix.ts": "118c5b1c6efa26ee1fe2e0451e64da8df370d80b31034dfe8fc11ba2c8b955a4",
"packages/artplayer/src/player/fullscreenWebMix.ts": "75b83a6093c7887ca8725f63c55503d0bf3aafdbbf2347b579120cb257503555",
"packages/artplayer/src/player/index.js": "c3eaeab0aa5015a51dfeeb4de6f25b02b8cd66e1f8471940367d628b5fd0bdec",
"packages/artplayer/src/player/loadedMix.ts": "6a4c4a08ed74346dad3e138b1dda22c8c9c12f175fe6d00da41b89c78cbe2c18",
"packages/artplayer/src/player/miniMix.js": "7355a66a23de47be91ab08dd28e8d54116aa2a603bd47b687014e4ab4736094e",
"packages/artplayer/src/player/optionInit.js": "9d1d9d276da2403a580498c62f947373e000d9cd6e4bef5831d272c3055b5592",
"packages/artplayer/src/player/pauseMix.ts": "5bbb68eab7dcf2170c944cabb8d4b82be4e6d7623e37ad6ea464b81c1028bc0b",
"packages/artplayer/src/player/pipMix.js": "15322751e235a70e6f9dc4d5e2b6b0ee6a8b93080370c6a90cd96268e101a8ab",
"packages/artplayer/src/player/playbackRateMix.ts": "e185c6ff52cd567f7fd7c30553bbd6c65517324d1d16c4948c3623ef457ef536",
"packages/artplayer/src/player/playedMix.ts": "5b6b89bf8dec1efb19cd51e46af1505bae006bdb3fb798e9dec7e3fb5cf0acaa",
"packages/artplayer/src/player/playingMix.ts": "968c9f6e6d23c233c77d62a593db64adcc6d1a2411f5f1c61a35c6996d6a54d9",
"packages/artplayer/src/player/playMix.ts": "60249fcacdc5b1bc6f33bcd806e8a4f5472cea21d40550b042794a75027fe34a",
"packages/artplayer/src/player/posterMix.js": "a96b0a9f5817919e373a5a55b43fe7ea94af357051c01ddd6ce9c68bc932ecba",
"packages/artplayer/src/player/qualityMix.ts": "8dfcb94f695cf983f313f00abe1aec183ceb2af4b398fbf62bf1d114257a8f43",
"packages/artplayer/src/player/rectMix.ts": "a741ee09fa3271df84363c5d73eac1b2e5adb39491f95f8112af81f1219ffed4",
"packages/artplayer/src/player/screenshotMix.js": "c64871270b224233ea67ff31c3fbc0c2e2eb35635da5e4d5a263b930fb96e6e7",
"packages/artplayer/src/player/seekMix.ts": "e6e1bf00e8441f1ce58d2a67a66f7e5e98c86d78620ff10a5e6268d54f13fadb",
"packages/artplayer/src/player/stateMix.ts": "e45b3209a93ab14665d19c5740d5c632187e9fe234b019a2a0263e26884ab3b2",
"packages/artplayer/src/player/subtitleOffsetMix.ts": "e7866cf3a6d9fe271a4c34973f7a961542a738e89a3647df85197cee5853ce8b",
"packages/artplayer/src/player/switchMix.ts": "b47e174ef0edb2b96ff29f2ddd3c9f2dd2a38713db2a81002b18c89ce71994e9",
"packages/artplayer/src/player/themeMix.js": "382acfaf113fbd1ee77c63dba72953d984ea715f2d99748a12b9eae70c081d49",
"packages/artplayer/src/player/thumbnailsMix.js": "43bdbd9b62a803f4966103c6718305a9ef665d25df26f735d3e34558ddf339bf",
"packages/artplayer/src/player/toggleMix.ts": "a3050136248e7d0b942387484c61286ac2b33a90724b1dc3460f5061be76f247",
"packages/artplayer/src/player/typeMix.js": "70920c8f785dca6d4bd66d82b996c37f1b69265190d7747ae8de298afae3604e",
"packages/artplayer/src/player/urlMix.ts": "23ee05577af66e3608f9c6d63f060bd3de68a99b1992e4e35e45f16a08a8c14f",
"packages/artplayer/src/player/volumeMix.ts": "d830bb27b984e0ab2f687943fb49abcbd0331d71fe96049fd81edbddaa64f07f",
"packages/artplayer/src/plugins/autoOrientation.js": "6ceb81bb3a778323f88ecaa78ef5646b642dad3f7d3176031c4ce53815a8d153",
"packages/artplayer/src/plugins/autoPlayback.js": "26f4a21691a2b2f767304b7d2fe40a35c599575651b9ace0c55a709a2be279b8",
"packages/artplayer/src/plugins/builtins.ts": "4b9f83959ebaea9991636ee670c3344a5ede5990d1631177ca470ecc156ddc67",
"packages/artplayer/src/plugins/fastForward.js": "d1f396efb22a279f9450fd02f85dc2245d274dbf0d36c99e87a5e5f895cc0029",
"packages/artplayer/src/plugins/index.ts": "28d13abc95cbedafadae0fdb682bf86fda9b705191c51843943a5dadddbd0a53",
"packages/artplayer/src/plugins/lock.js": "843caf8c7981463e8dadf1510cd028e7e70049f5a10038bd66dfccbc63bb7886",
"packages/artplayer/src/plugins/miniProgressBar.js": "3bf48d30c209105a0144c17062d6ea4e2bdd351b1bc928df73ea1680b78462e2",
"packages/artplayer/src/plugins/registration.ts": "9891686c32aeaa33c48ca500cf30ab24ffbb0835c6248769b4c645a5ff20129b",
"packages/artplayer/src/plugins/types.ts": "6823098d5ec1bf053f60ddfdd36bfcd3f06f3b528cc5dd30fd676a1318dac590",
"packages/artplayer/src/scheme/index.ts": "dc4a3b5e08478cf72b46b0b59c023359ad5f79b891ea8a6a119ce24a5251821a",
"packages/artplayer/src/setting/activity.ts": "699ceed4bdeddb6d48eacf79c8dbf737f5d2d8c4983be70205322b14807a91b7",
"packages/artplayer/src/setting/aspectRatio.ts": "a16eb58fadf77f0a9009cf162d957ffd59d9b8fde83cc193d510fd642e3456cb",
"packages/artplayer/src/setting/checkpoint.ts": "9d3bff311f9b15c067ca49661af4f48de939f266b8077e7c2f0d66edef22dde6",
"packages/artplayer/src/setting/events.ts": "f7a3347ca146232caf2a8c9a5a18731cf7bc91dca4cd36370e0265c6eb70df53",
"packages/artplayer/src/setting/flip.ts": "e72a4ae33300d9e48880897a4f85a04fa6d9a5fff7b06ecaf9081cfb1bb14404",
"packages/artplayer/src/setting/index.ts": "f1e8a302077a8ccfa77c17ce344bdd6816943e77b5876c7f623314ff846742f7",
"packages/artplayer/src/setting/layout.ts": "584604e8fffaeaf1d633d1ed45e64d94595c2a56976e3a3f7ede01839b83a37d",
"packages/artplayer/src/setting/model.ts": "c47348f0f9494c4ce8ea5561bcde6eb698fb11e9197ccdc1ca1359a6d5e370dd",
"packages/artplayer/src/setting/panels.ts": "64d48720971763985a2a652a18c2ed2f32300c3553d56b52049ae40c4dfceae1",
"packages/artplayer/src/setting/playbackRate.ts": "2e70e0d2a33cb0ec9fcdd6d22424979fed7b2e01f87015d6be4e520d5cf4e88a",
"packages/artplayer/src/setting/registration.ts": "c29db62b5d90e0efa4a926c753015e65e96a47add3f5b8840ee51017052edf7c",
"packages/artplayer/src/setting/render.ts": "2bb29994c60d0c47517bdf7cacb6afac7d65075f0a67d895a3ce52250b668232",
"packages/artplayer/src/setting/resources.ts": "64c28cc2cb08cb87b0864ab957d7999bc94931be4788f282d6f190b8806a81fd",
"packages/artplayer/src/setting/selection.ts": "a1bc04b99bda45cc1a2afcf2dd0925c88ccb55e169b971105277883ae2ccba0c",
"packages/artplayer/src/setting/subtitleOffset.ts": "86513835d4e542ac39e9d551507473e3e14c71fd0d5ea725267008ecbff6ab83",
"packages/artplayer/src/setting/types.ts": "799e7adeac76c4a327201ec66f10d8f25f92387409f1e5e65f265df7711973e1",
"packages/artplayer/src/setting/update.ts": "3725fd69b369e4a345b1657ab559a915ab967c20088a093e288ffa7370cda264",
"packages/artplayer/src/source/listen.ts": "c0596b98258c1544c8e4b762509646428450e56588da36908cb8583b8b716c37",
"packages/artplayer/src/source/operation.ts": "a6cb65594a5b28247e3a6889fe0a2f47a34aeb08cdde5f0125800a87862cd559",
"packages/artplayer/src/source/switch.ts": "697ab56a765f957e3ce1da076c69cfc11dbf0901efecae0199cd94c729e0b236",
"packages/artplayer/src/source/types.ts": "9456d3d2f798188b730d99ef015e8a0a145598b0fe3ad1036b84f4a7cc1c5e6d",
"packages/artplayer/src/storage.js": "92067265b7f1104c4680e27e35726cf033811c6551e7c38d66028f2dd5300491",
"packages/artplayer/src/style/artplayer.less": "4e8513138d5d10c05b66986977d24ed17f1de3c0df3816dec6d90db7a97b5da3",
"packages/artplayer/src/style/aspectRatio.less": "091931a0cdc83e65a669332811ef3276b85bca9e55f7fff42aa46893d728c1ef",
"packages/artplayer/src/style/backdrop.less": "6c2cd0fa200d1f3525df98c3c56dbdc553445724aece3feb0386949245813343",
"packages/artplayer/src/style/bottom.less": "6fb307d8449fd04b804d5e72ea0f175f8aa9c9dd2bf1efbf756f800b39ac97ab",
"packages/artplayer/src/style/contextmenu.less": "70f6c7e26dc96652b659c7bf6fdad92737ed477dea69d253c57dc7266c76586d",
"packages/artplayer/src/style/controls.less": "eb6d4bf4e4ff903d724d45f3ff60f0cf10c02e622fba195555a8179100cdce87",
"packages/artplayer/src/style/danmuku.less": "e4215ee9fa2f1d0599e7311a2415cc975138265b7c6af0448d27d21b6c9e3511",
"packages/artplayer/src/style/flip.less": "5d3c7a81df9be455c2f683ebf48f39597d218dc2144addacb72ee1736bac3b7f",
"packages/artplayer/src/style/fullscreen.less": "dd350d3a0fc556d59b622a53c786b8c2a583e8fb28176e4b9e91baff90479496",
"packages/artplayer/src/style/fullscreenWeb.less": "d74e785c2fbbb2976e131620e751326f622f2916eceff9f2333059ca3296cdc9",
"packages/artplayer/src/style/hideCursor.less": "09a4607c8b0005dc199828172aa8dda395f861e407769b9696d793476c4a7823",
"packages/artplayer/src/style/hint.less": "ae9f3115dfdb1e2d7b8249497c080e6a25f9667f915f6e89820338d67bbc2d85",
"packages/artplayer/src/style/icon.less": "52d8f75c1d193e6160f57ca6997c3f9135adad8eb2338ed60c1eb8e5fe443be5",
"packages/artplayer/src/style/index.less": "ec67999b458d5545a4f17eeda922872823d655da012fa69113902629ad847548",
"packages/artplayer/src/style/index.ts": "856afc037ffcd4d18c4b54901ddbb30e310014553fe9d19560e34fb54b8378c1",
"packages/artplayer/src/style/info.less": "fa56d1b8c8936218e581b2426026e4ba85cd5fea20d045c4f69aca96be4059be",
"packages/artplayer/src/style/inject.ts": "9cddbe51b5de0da74f27c045ab982b509c3854cc6b81eeb02a6b974914760a4d",
"packages/artplayer/src/style/layers.less": "9653c581190bb22d60a706109819cd010908898740701c9526957b5682991c5f",
"packages/artplayer/src/style/loading.less": "185b0f05274196ba71fdb1d088b55af568f3342a495490c99c3f0e058a40659e",
"packages/artplayer/src/style/mask.less": "d1239a171b71ea6184cb4aee6660d74d6ffc0c48f7c47c3cf5fbe52d2e93ac92",
"packages/artplayer/src/style/mini.less": "a352e7a63f9529f18e6e389202fc277c500bc70acb38c7aa6eea9d7dba831c0f",
"packages/artplayer/src/style/mixins.less": "c6be95aa682f266749fe37cb89796fbec6488abc537174f817da79bdac6e00b5",
"packages/artplayer/src/style/mobile.less": "ff3f64fca5d2dd7ddd50385d88d9b2fa1a645909488ab7a0978ba4f1670b7c0e",
"packages/artplayer/src/style/notice.less": "72a935c0effc557a9d7ab3baccb2c3b77fc3a1783450b4e3178a40a5325fe369",
"packages/artplayer/src/style/plugins.less": "589d05e12a62e04fe848b36ba748433d23f0b909a04e861b1d6737f2799be183",
"packages/artplayer/src/style/poster.less": "36b38fe138f8c9537ff4e93f7ec4f2dbdba1ec41d243d741ddf1da7c6581e546",
"packages/artplayer/src/style/progress.less": "59b9d95d49dbcb1be40752d7086239b6de5925b0574da231199d999e22ff4990",
"packages/artplayer/src/style/selector.less": "295012ba379a7e957a24476616a0c3623d6a06e82ebbdaa45b916dd6412d7f4f",
"packages/artplayer/src/style/setting.less": "743837b3c84fc5fee40120598bd484c10bd4202b371002709c269a700919dda2",
"packages/artplayer/src/style/subtitle.less": "273cacb63ec0214ba434dcf19a4a455dbbdd408fb2d1749de4878804c1371e41",
"packages/artplayer/src/style/variables.less": "703739b2bdfc77bfde063d8788ef6b6a55b8730764baae8e7d755b7c54d8ecc7",
"packages/artplayer/src/style/video.less": "b89c6f6e27592d49825c68c9ab98d46e81cb1e5add855863f89d19637f98e35f",
"packages/artplayer/src/style/volume.less": "a3954794a9478d3872202b90d349c873cbb51639dcd7d1e3d73a55ce635fcd99",
"packages/artplayer/src/subtitle/index.ts": "b4ead680dc82fa47c53d1ec7e5fb8bf94abbf55730d5d1130ad8d544cb42adc8",
"packages/artplayer/src/subtitle/parse.ts": "7db540c5edb1d2312eed82757fd15a7ce6a28996ba20d0087e56730f21dede74",
"packages/artplayer/src/subtitle/render.ts": "96493fc35680af635a8db225a8bf002208df79ed48b1d4ee6cc73587543add5c",
"packages/artplayer/src/subtitle/request.ts": "543c964571bd5ab984bc1adc9f64a8917dd134ed2892ad10b30ed9c859f34119",
"packages/artplayer/src/subtitle/state.ts": "9f79b982d66747170bc39bdaf99b65676310965f23da62a4dec15547c756a56b",
"packages/artplayer/src/subtitle/track.ts": "707f4c43af4ebf4b87521f6f577168460e739274d0cec28b9efe5704d2540d18",
"packages/artplayer/src/subtitle/types.ts": "e56db829d7588f3a5dfafd6e8377f93f0892cfe442710be3740e1136812dd2bd",
"packages/artplayer/src/template/html.ts": "351c472bfd01fa5ba1a4e347ed31bd5faaf823d9b4c5cab1ce8c651e3b175021",
"packages/artplayer/src/template/nodes.ts": "1572746a636647e4d313cc8752d3a110ff81fddb390ad7b0feb6a0db0d42b9d4",
"packages/artplayer/src/template/types.ts": "23f67e78ee43f9ad9099c94f28e51c6113b167603ffbbcf2d6ca2b68cc40b803",
"packages/artplayer/src/template.ts": "05c1499adc5dce27addf5761bec58d803f526d9b5b62c367c631e4cf2991391d",
"packages/artplayer/src/utils/compatibility.js": "51290838ef62be8e4251f4db8ecdd45a626cecd8c732e4e53a1c55df8d454a13",
"packages/artplayer/src/utils/component.ts": "d04a66213406ae3223326c2d470363e197dd1540519caa632f9d06378af989cc",
"packages/artplayer/src/utils/dom.js": "a625a0d832f717fbdf186099d1ace1f0dd51f1ea871417ffa856a7186206fb91",
"packages/artplayer/src/utils/emitter.ts": "26a93d3ff35b53e71a5f3bb22c252a854559d8151ede50a66a28d9eefae6c008",
"packages/artplayer/src/utils/error.ts": "ba303c7d4e4228919088f9d608fa364596f5853ee646eda2008deee354866f38",
"packages/artplayer/src/utils/file.ts": "ea71d7fc0e818d473bf5a596211ba9d571e4febdf1878014eadefa8d0a98d4cc",
"packages/artplayer/src/utils/format.ts": "891df5b7542b713aef46bf2b93924f42e68eee574afd802db3e6c62ff2655ede",
"packages/artplayer/src/utils/index.ts": "5211af833dddb2937d25dcc772740d62f9d5c5039ce5505bffff0796d4afd756",
"packages/artplayer/src/utils/property.ts": "c737d4582a4382f4038d12dafdf8b43418e508c9b8b859b2f0cc17c20f370343",
"packages/artplayer/src/utils/subtitle.ts": "f058851d11a418e321a8a498472b67fa535fec2c7b5d3df739b6b4f263d00337",
"packages/artplayer/src/utils/time.ts": "b8ce3d50865f6c343e8e3ce9f5c87de565a87b4e9fbcbccbe7030e30e80b93fe"
},
"artifacts": {
"packages/artplayer/dist/artplayer.js": "3d07659a29f5fc0d62615f474428d5a850db6a0cc7d5c4f5877fe083218182fc",
"packages/artplayer/dist/artplayer.legacy.js": "c80c7b4b9e7d7929b112b9eb6bcc3889815d924e6035fe96820f7d89571fe16c",
"packages/artplayer/dist/artplayer.mjs": "bfdb23a5f105d51566a0d7110acefb7f19888451156980a1f6172ca44a5b8466"
},
"tests": {
"test/browser/display-native.spec.js": "92897128509ea99f5a0c35f68172d0765095fccd78a8d8a6435cc4bb1c966d77",
"test/browser/display-web.spec.js": "154240102bc08dc0d4242807dc3b8fb97c961ae76ca20b6e34d6847094f09805",
"test/browser/subtitle-lifecycle.spec.js": "ba91719ccdc9e539aee252d80deaba622fd6e5cb30cdd74697658af1e49dfd58",
"test/display-native.test.js": "204f38fff05e2ff6e88d6875e9fe8df30558192450ca7a9685c739c45211ae70",
"package.json": "37101b2f2cb1940a8302504df3b0af77686c4c1aeb9920fdc3c31df0b8843149"
},
"ci": {
"passed": 228,
"unit": 199,
"engineering": 4,
"baseline": 25,
"strictSourceFiles": 144,
"logSha256": "f1b7c8637e9ff114036017794f7bd13700bba5397e65d7a36e14a92399832d87"
},
"browser": {
"stats": {
"startTime": "2026-09-11T00:01:36.166Z",
"duration": 157683.99099999998,
"expected": 693,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"versions": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"counts": {
"chromium": 231,
"firefox": 231,
"webkit": 231
},
"candidateSha256": "5dc3d53e13486d2b93be4935d37ba3e624a10724bd6b804ed364ab922d9988b4",
"nativeCases": [
{
"file": "display-native.spec.js",
"title": "published: native fullscreen belongs to its player and other players cannot exit it",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "published: destroyed players stop receiving later native fullscreen events",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "published: rejected native fullscreen request removes its temporary change listener",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 2
},
{
"file": "display-native.spec.js",
"title": "candidate: native fullscreen belongs to its player and other players cannot exit it",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: destroyed players stop receiving later native fullscreen events",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: rejected native fullscreen request removes its temporary change listener",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: fullscreen assignment rejection shows notice without an unhandled rejection",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: reentrant entry preserves real fullscreen UI and event order",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry promise-exit releases a late own entry",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry promise-destroy releases a late own entry",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry void-exit releases a late own entry",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry void-destroy releases a late own entry",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: cancelling an actual native request in the initiating click releases fullscreen",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "candidate: rejected exit retains actual fullscreen state and can be retried",
"engine": "chromium",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 1
},
{
"file": "display-native.spec.js",
"title": "published: native fullscreen belongs to its player and other players cannot exit it",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "published: destroyed players stop receiving later native fullscreen events",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "published: rejected native fullscreen request removes its temporary change listener",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: native fullscreen belongs to its player and other players cannot exit it",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: destroyed players stop receiving later native fullscreen events",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: rejected native fullscreen request removes its temporary change listener",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: fullscreen assignment rejection shows notice without an unhandled rejection",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: reentrant entry preserves real fullscreen UI and event order",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry promise-exit releases a late own entry",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry promise-destroy releases a late own entry",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry void-exit releases a late own entry",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry void-destroy releases a late own entry",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: cancelling an actual native request in the initiating click releases fullscreen",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: rejected exit retains actual fullscreen state and can be retried",
"engine": "firefox",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "published: native fullscreen belongs to its player and other players cannot exit it",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "published: destroyed players stop receiving later native fullscreen events",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "published: rejected native fullscreen request removes its temporary change listener",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: native fullscreen belongs to its player and other players cannot exit it",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: destroyed players stop receiving later native fullscreen events",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: rejected native fullscreen request removes its temporary change listener",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: fullscreen assignment rejection shows notice without an unhandled rejection",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: reentrant entry preserves real fullscreen UI and event order",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry promise-exit releases a late own entry",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry promise-destroy releases a late own entry",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry void-exit releases a late own entry",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: pending native entry void-destroy releases a late own entry",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: cancelling an actual native request in the initiating click releases fullscreen",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
},
{
"file": "display-native.spec.js",
"title": "candidate: rejected exit retains actual fullscreen state and can be retried",
"engine": "webkit",
"status": "passed",
"unhandledErrors": [],
"consoleErrors": [],
"failedRequestCount": 0
}
],
"reportSha256": "49120c1311187454412037e21b398b4d1a6acf40ed6b774e17738f3c92dbe6aa"
},
"baseline": {
"stats": {
"startTime": "2026-09-10T23:43:10.375Z",
"duration": 12331.226,
"expected": 9,
"skipped": 0,
"unexpected": 9,
"flaky": 0
},
"sha256": "ba10fe9ecdffc1ad0414e816d39e8cb900511914c5b67430d8849fbcae536b67"
},
"earlierRuns": [
{
"name": "core16-native-first.json",
"stats": {
"startTime": "2026-09-10T23:51:35.856Z",
"duration": 23404.386000000002,
"expected": 69,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"sha256": "95d9aa620d5043684b3ae195116c147267353b6cf0f7317a44c72203b0d005a5"
},
{
"name": "core16-native-expanded.json",
"stats": {
"startTime": "2026-09-10T23:58:16.370Z",
"duration": 46784.162,
"expected": 159,
"skipped": 0,
"unexpected": 3,
"flaky": 0
},
"sha256": "64d7354e98e60fefc54694cbe1b3a466095e8388d6239fbd3ef7c040bf58fba5"
},
{
"name": "core16-native-recovery.json",
"stats": {
"startTime": "2026-09-11T00:00:21.333Z",
"duration": 47202.782,
"expected": 174,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"sha256": "2d0b3dc290c3fafc93e8533f51bf4684785e76b27451d1206172c9bf372485f6"
}
],
"build": {
"logSha256": "133fcdd21d6ba4597663fd1c9f2d0e7be8fcf0be8a5b7faf7cb66834db226397",
"i18nLogSha256": "404c25d0f1d30e4f70cf951411d2197651a616a5b486b80582fa0ae41e591efe"
},
"remaining": [
"WebKit video-only fallback, PiP, mini, sizing and orientation migration",
"Mode composition, destroy/exit failure boundaries and physical-device evidence",
"Full final installed UMD/legacy and consumer acceptance; risks remain open; CORE-16 has no completed-task commit"
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,144 @@
# CORE-16 显示模式迁移
状态:完成。以下阶段记录保留当时的进度;当前交付结论以末尾最终验收为准。
## 第一批:页面全屏和位置快照
fullscreenWebMix.ts 保留旧 boolean 属性及真实 DOM class getter,委托 display/web-fullscreen.ts 管理样式、位置快照和操作代次。display/placement.ts 保存原父节点与后继节点,display/types.ts 声明最小宿主。实际核心入口仍然是 JS,不把单个模式迁移计作全部显示服务完成。
浏览器先对发布包与迁移前候选复现:重复进入覆盖原始样式,空样式退出后残留 100% 宽高;body 模式丢失原父节点/兄弟顺序,静态 flag 中途改变会影响返回位置;destroy 不恢复 body 中的播放器,留下可见孤立节点。36 项基线中发布包 18 项确认缺陷,候选 18 项按目标行为失败,原始报告保留。
修正策略:
- 一次进入会话只捕获一次样式和位置。重复 true 仍保留原 true 事件,不再覆盖快照。
- false 恢复实际原父节点和后继,不依赖退出时的 FULLSCREEN_WEB_IN_BODY 值;后继已移除则追加到原父节点。
- 空 style 与未设置 style 分别按原属性恢复。resize 可继续更新控件自身的 CSS 变量,不覆盖用户宽/高/颜色。
- 实例销毁先恢复自己移出的播放器,再由现有 template.destroy 执行 removeHtml;资源清理不额外发 fullscreenWeb/resize 事件。
- 每次 setter 都有代次;事件监听者退出/重新进入/销毁后,旧操作不再写入或发出过期 resize。
- 进入失败尝试恢复原布局;退出移动失败保留快照,可由下一次 false 重试。用户 DOM/事件回调的任意外部副作用不属于通用事务回滚。
兼容边界:普通调用仍同步、事件顺序为 fullscreenWeb 再 resize,class/getter/静态 flag 名称不变。state 仍通过旧门面退出其他模式;本批未解决那些模式自己的异步退出和权限问题。公开声明未修改。
## 阶段验证与限制
- 已新增真实浏览器的新旧恢复契约和候选重入/失败用例。
- 首轮候选样式断言误把 CORE-13 的 --art-controls-height 更新计作恢复失败;原始报告保留。改为退出事件内验证精确快照、resize 后验证宽高颜色,并附带完整前后样式。
- 本批 CI 210 项、139 个生产 TS 文件严格检查、完整工作区三浏览器 651 项通过,其中页面全屏 51 项。无重试或跳过;四个 TS 源文件涉及页面全屏门面、宿主、位置与恢复逻辑。见 [阶段证据](../baselines/display-web-validation.json)。
- 重新用同一浏览器构建配置生成候选,核对 SHA-256 与全部 651 项的 manifest 一致;核心三个分发格式和 docs/compiled 字节一致,构建与语言生成通过。本批尚未验证安装 UMD/legacy,不把工作区验证说成发布验收。
- 原生全屏/PiP 权限、mini 拖拽、方向锁与退出失败、完整安装 UMD/legacy 都仍未完成 CORE-16 验收。后续还要验证旋转监听者与用户原始宽高恢复的组合,以及销毁期间位置恢复本身失败的处理;两个新增风险保持 open。
- 无新依赖或脚本;继续 Node 24.21.0 / Yarn 1.22.22。完成 CORE-16 后才作对应独立本地提交,不推送或发布。
## 第二批:原生 document 全屏
fullscreenMix.ts 保留首个 metadata 后安装属性的时机,调用 display/fullscreen-adapter.ts、native-fullscreen.ts、fullscreen-request.ts;原 vendor screenfull 内容和前缀映射不变。新增最小 FullscreenHost/NativeFullscreenAdapter 类型,公开声明不变。
发布包在三个引擎均复现:一个实例进入会使另一实例报告全屏并可退出别人的全屏;destroy(false) 后仍会收到后续全屏事件和 class 更新;请求拒绝后遗留临时 change 监听。登记 BASE-DOM-10、BASE-LIFE-18,保持 open 到最终安装产物验收。
- getter 与退出按本实例 player/video 的真实归属判断;其他实例的事件不再污染本实例。普通 fullscreen → state/class → resize 顺序保留。
- 请求在调用栈中同步发起,不提前 await,保留用户手势权限。原生 Promise 拒绝保留错误对象;void 前缀 API 使用 change/error 结算。
- 每个请求的临时监听和实例永久监听分别随操作/实例 scope 清理。取消及时结算;现代 Promise 的晚到进入及销毁均作归属检查后退出。普通属性赋值提供 notice 和内部 rejection handler,直接调用 descriptor setter 仍能 await/catch 原错误。
- 无法取消且不返回 Promise 的旧 API 使用一个按 document 共享的晚到事件 guard。只有该取消路径才创建它;弱键保存目标节点,值只持有 native exit,不引用 art/scope。新请求清除旧标记,其他节点不被退出。此监听有意保留到 document 结束,不计为实例监听器泄漏。
- 补测修复了本批初版的重入 true 丢失 class/resize、fullscreenerror 抢先覆盖原始 Promise 错误,以及取消后 change 先于 Promise 完成导致短暂旧状态的问题。
验证过程:初版新旧原生/页面全屏 69 项通过;新增组合首轮 159 通过、3 失败,原因是 notice 测试未限定主实例而匹配两个元素,已改为明确主容器定位,原始报告保留。随后原生/页面全屏/字幕 174 项三引擎通过;最后一处晚到 change 修正后,完整工作区三浏览器 693 项通过,其中原生全屏 42 项、页面全屏 51 项。新增 18 项 Node 测试已接入原有 test:unit/CI,合计 228 项通过;144 个生产 TS 文件及五组消费者严格检查通过。没有新依赖。
核心三个格式与语言重新生成,docs/compiled 与核心分发产物字节相同。独立用浏览器配置重建候选,SHA-256 为 5dc3d53e13486d2b93be4935d37ba3e624a10724bd6b804ed364ab922d9988b4,与 693 项报告中全部 manifest 一致。详见 [阶段证据](../baselines/display-native-validation.json);该证据明确标记 partial,未运行最终安装 UMD/legacy 验收。
限制:桌面实际全屏/用户手势与受控 Promise/void 故障注入分别记录。受控 void API 不等于旧 Safari/iOS 实机验证;video-only WebKit fallback 目前只保留旧实现并清理订阅,尚未完成语义修正。销毁后的 native exit 为 best effort,浏览器拒绝仍可能保留原生全屏;普通失败退出保留实际 getter/class 并允许重试。最终模式组合与安装 UMD/legacy 尚未验收。
## 第三批:video-only WebKit 全屏
fullscreenMix.ts 的 fallback 委托 display/video-fullscreen.ts;display/video-fullscreen-state.ts 管理实际状态判定和无实例引用的晚到取消。依照 [Apple 的视频全屏事件说明](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/ControllingMediaWithJavaScript/ControllingMediaWithJavaScript.html),补充 webkitbeginfullscreen / webkitendfullscreen 及 presentation-mode 监听,并保留 document:webkitfullscreenchange 的历史桥接。
受控浏览器基线确认发布包在视频自身显示全屏时 getter 仍为 false,原生进入/退出事件不更新 ArtPlayer;销毁时视频保持全屏。登记 BASE-DOM-11、BASE-LIFE-19。浏览器 fixture 明确关闭 document 全屏能力并模拟视频方法/状态,不能把这项复现解释为已在 iPhone 上复现。首轮 fixture 未给原型方法设置 writable,影响新增失败注入场景;保留报告,修正后才用于验证这些场景。前两组新旧状态/销毁基线不依赖方法覆写,仍可使用。
普通 setter 仍同步、返回 undefined;原生同步异常保持同一对象并允许下一次调用重试。新状态读取优先区分 fullscreen 与 picture-in-picture;没有 presentation 属性时使用 webkitDisplayingFullscreen,再回退到 document/原生事件。多种原生通知只发一次相同状态变化,事件内重入/销毁停止旧 resize。
实例销毁时释放所有实例监听,退出当前视频全屏。不能取消的 pending void 进入只保留视频自身的静态监听与 WeakSet 标记;回调不引用 art/scope,新进入会移除旧取消标记。正常销毁退出的同步错误由现有生命周期清理收集器传播;晚到取消退出是 best effort。该路径不添加 document 全局监听。
新增 9 项 Node 测试已接入 test:unit,包括只有事件而没有状态属性的 shim、PiP 状态区分、事件顺序、重入、失败与晚到清理;没有新依赖。首轮 video-only 与 document 全屏 60 项三引擎通过。最终 CI 237 项、146 个生产 TS 文件和五组消费者严格检查通过;当前源码的 video-only/document/web 全屏与字幕组合 192 项三引擎通过,其中 fallback 18 项。该次是相关组合回归,没有重跑完整浏览器套件。
核心三个格式与语言生成通过,docs/compiled 与分发字节相同;独立重建候选并核对全部报告 manifest。详见 [video-only 阶段证据](../baselines/display-video-validation.json),仍标记 partial,物理设备与最终安装产物门槛保持开放。
PiP 下一步入口审查发现:原生分支 getter 返回 document.pictureInPictureElement(Element/null),WebKit 分支返回 boolean,而公开声明统一为 boolean。迁移时必须保留旧合法 JS 返回形态,同时处理跨实例归属和公开类型协调;不能直接把所有返回强制转为 boolean 来掩盖差异。该点目前是源码观察,下一批补发布基线与消费者用例。
## 第四批:PiP
pipMix.ts 选择可调用的原生或 WebKit 能力;native-pip.ts 与 webkit-pip.ts 分别拥有其状态、事件和销毁。发布包复现确认:原生 getter 返回 Element/null,setter 返回 undefined;WebKit getter 为 boolean,重复 setter 保持同步 pip 通知。本批保留这些形态,内部 PipProperty 使用真实联合类型,不修改历史公开 boolean 声明;差异登记 BASE-TYPE-10 并由 CORE-21 继续协调。
原生分支按本视频归属读取/退出,避免关掉别人的 PiP;销毁时先移除实例监听,再退出自己持有的窗口。进入调用不 await,保留手势调用栈。异步拒绝显示原错误 notice,旧操作不覆盖新 notice;由于原 setter 返回 void,内部处理拒绝以避免无消费者的 unhandledrejection,没有伪造一个新 Promise 返回。同步原生错误仍同步抛出。取消或销毁后的晚到进入会检查归属后退出。
WebKit 分支保留初始 inline 设置和重复 setter 事件;补充原生 UI 的 presentation/PiP 事件,避免多种原生通知造成重复事件。进入时检查当前能力,媒体就绪后可以重试;false 不关闭另一种 fullscreen presentation。pending void 进入取消只保留视频自身的静态 guard 与弱标记,不引用 art/scope。同步销毁失败与晚到清理限制沿用显示生命周期规则。
基线 27 项中,旧发布的 9 项和候选普通契约 3 项通过,其余候选 15 项按新目标失败,包含跨视频归属、销毁、晚到进入和未处理拒绝。迁移后首轮 native PiP/video-only 45 项通过;新增 WebKit 组合后 162 项通过。补测修复了原生进入在 mode 同步中被取消时发出未曾对应 true 的 false 事件;WebKit 的显式 false setter 仍保留原 false 通知。14 项 Node 测试已接入 test:unit;无新依赖。
实际原生验证:当前 Chromium、Firefox 均支持标准 API,成功获取非零大小 PiP 窗口、记录视频播放进度,并验证向页面全屏/原生全屏切换;当前 WebKit 标准 API 不可用,记录能力边界。受控 WebKit 用例另行验证 fallback,不将其算成原生 PiP 或物理 iOS 通过。
最终本批 CI 251 项、149 个生产 TS 文件及五组消费者严格检查通过;PiP/全屏组合 162 项三浏览器通过,其中 PiP 51 项。核心三个分发格式和语言生成通过,docs/compiled 字节一致;独立重建候选并逐个核对浏览器 manifest。见 [PiP 阶段证据](../baselines/display-pip-validation.json),其中单独保存实际 PiP 能力/窗口/播放证据,明确标记 partial;没有进行最终安装 UMD/legacy 验收。
## 第五批:mini
miniMix.ts 保留 boolean 门面;display/mini.ts 管理视频位置和操作代次,mini-view.ts 管理浮窗/播放按钮/订阅,mini-drag.ts 管理拖拽与 document 订阅,mini-layout.ts 为纯位置计算。五个新 TS 文件按职责分拆,共享此前的 Placement 工具,没有新依赖。
新旧浏览器复现确认:旧退出将视频直接 prepend 到 player,丢失原父节点和后继;两种 destroy 都留下 body 浮窗。登记 BASE-DOM-13、BASE-LIFE-21,保持 open。最初隐藏拖拽测试只检查形状而未比较持久值,已增强为前后存储完全相同,不将最初该条通过视为清理证据。
- 每轮首次进入记录视频实际 parent/next;重复进入不覆盖。退出失败保留快照,可重试;恢复前先分离旧快照,避免 DOM 回调重入时抹掉新会话的位置。
- 自建浮窗、播放与拖拽订阅均由实例 scope 清理;隐藏停止拖拽、保留可复用浮窗,销毁恢复视频并移除自建节点。调用方预置的 $mini 不删除,销毁恢复其初始 display。
- 保留首次 video-first/default display、重用 video-last/flex 的旧 DOM/样式细节,保留重复 setter 的 mini 事件和 top/left 存储键。
- 拖拽使用固定定位对应的 client 坐标;结束时限制在视口内。普通默认仍为 50px 边距,非有限/越界存储重置,小视口通过 max-width/max-height 保持可见。
- 自定义图标 getter 可能隐藏/销毁/再次进入 mini。重复创建的受控复现最初在三个引擎失败,已将创建串行化为单个浮窗,并验证未完成视图的清理和失败后重试。
纯位置计算 4 项 Node 测试接入 test:unit;新增内部 boolean/数字坐标类型用例。首轮 21 项、扩展 51 项通过;修正创建重入后 219 项 mini/PiP/全屏组合通过。补充销毁后的拖拽订阅验证后,最终组合 222 项通过,其中 mini 60 项;CI 255 项、154 个生产 TS 文件和五组消费者严格检查通过。初版 Cleanup 回调的 void/undefined 类型差异已修正,保留失败日志。
核心三个分发格式及语言生成通过,docs/compiled 字节一致;重新生成候选并逐个核对 222 项 manifest。见 [mini 阶段证据](../baselines/display-mini-validation.json)。本批没有重跑完整浏览器套件或最终安装 UMD/legacy;mobile touch 拖拽和物理设备验证未计作通过。
下一步处理尺寸/方向组合及剩余失败退出边界。回退时恢复对应旧 mixin,删除 display 新模块及类型用例,并由构建重新生成产物;保留已复现的基线记录。
## 第六批:尺寸、宽高比与翻转
autoSizeMix、autoHeightMix、aspectRatioMix、flipMix 迁移 TS,display/sizing.ts 集中纯尺寸计算,sizing-types.ts 限定各门面的最小依赖。保留自己的不可枚举/不可配置属性、可抽取方法、同步 undefined 返回、正常布局和事件;宽高比仍用原 split/Number 转换,保留多段字符串的旧解析、dataset、notice 与重复事件;翻转仍允许自定义字符串和 falsy 默认值。没有新依赖或公开声明变更。
发布基线 18 项中 15 通过、候选目标 3 失败,复现媒体尺寸为 0 时旧 autoSize 局部覆盖宽度、autoHeight 发出 NaN。新增 BASE-DOM-14,候选对未就绪/隐藏/非有限几何不改布局、不发无效测量,尺寸恢复后正常应用。非法比例仍保存输入并发原事件/提示,CSS 不再局部改写;这属于异常输入的布局修正。销毁后的调用不再改写残留节点。
4 项纯计算测试覆盖比例保持、容器边界、尺寸未就绪后的恢复、非有限值和浮点溢出/下溢。浏览器尺寸用例 33 项覆盖发布/候选有效比例、属性描述符、可抽取方法、解析、隐藏后恢复、非法输入和销毁。扩展首轮 30 通过、3 失败来自测试错误地把 notice.show 的 boolean getter 当字符串;已改为检查真实提示节点,保留该失败日志。
最终本批 CI 259 项(230 单元、4 工程、25 基线)、160 个生产 TS 文件及五组消费者严格检查通过。尺寸/mini/PiP/全屏组合 255 项三浏览器通过,其中尺寸 33 项;核心三个格式和 i18n 构建通过,docs/compiled 字节一致。重新构建的候选指纹逐一匹配 255 项 manifest,见 [尺寸阶段证据](../baselines/display-sizing-validation.json)。这不是全量浏览器或最终安装包验证。
CORE-16 仍在实施:自动方向、旋转样式组合、恢复失败以及最终安装 UMD/legacy 验收尚未结束,不作完成任务提交。回退本批时恢复四个 JS 门面、删除 sizing 模块/类型/测试并用仓库脚本重建,保留基线缺陷记录。
## 第七批:自动方向
autoOrientation.ts 保留内置插件名称、只读 state、移动端启用条件和横竖不匹配规则;拆出 orientation-web.ts、orientation-native.ts、orientation-types.ts。原泛型插件注册器不声明完整 JS 构造宿主,因此仅在内置装配处桥接工厂类型;实际实现使用最小 OrientationHost,完整主入口类型仍由 CORE-21 接续。无新依赖、无公开声明变化。
基线 12 项中旧发布 6 项通过、候选目标 6 项失败,复现退出旋转会清空调用方样式、方向 lock 晚到后会重新添加 active class 且未 unlock。分别登记 BASE-DOM-15 和 BASE-LIFE-22。实现由实例 scope 拥有订阅与 timer,退出/销毁取消旧会话延时;重复全屏重新应用旋转尺寸但不覆盖首次快照。fullscreenWeb 退出先恢复完整样式,旋转 false 不再清空;销毁时旋转清理先于 fullscreenWeb 最终恢复。
原生方向锁立即调用,成功后只有当前活跃请求能标记 class;退出/销毁立即尝试 unlock,晚到成功再次释放。弱平台对象 token 限定请求归属,旧实例不能 unlock 新实例的锁;旧错误不覆盖新 notice,当前错误保留身份并允许重试。锁成功前后的取消均有单元用例。外部代码直接调用方向 API 无法被 ArtPlayer 自动追踪;平台拒绝 unlock 仍按旧行为 best effort。标准依据为 [Screen Orientation](https://www.w3.org/TR/screen-orientation/),不将受控 API 回调当作物理设备能力证明。
8 项 Node 测试接入 test:unit。首次浏览器迁移 9 通过、3 失败:源码已恢复用户四个属性,但 resize 合法添加 --art-controls-height,原测试错误地比较整个 style。诊断保存了实际前后字符串;改为逐一比较受管理属性,destroy 仍检查完整快照。扩展轮 17 通过、7 失败,其中重复进入覆盖旋转尺寸是实际缺陷,已修复;计时器测试在 Firefox 的时钟继续流逝,已用 pauseAt 明确冻结后按时间推进。修正后 24 项通过,再加入 pending destroy 与真实旋转播放/比例/翻转组合。
最终方向 30 项、全部当前显示模块组合 285 项三浏览器通过;CI 267 项(238 单元、4 工程、25 基线)、164 个生产 TS 文件及五组消费者严格检查通过。核心三个格式和 i18n 构建通过,docs/compiled 一致,独立重建指纹与全部 285 项 manifest 匹配,见 [方向阶段证据](../baselines/display-orientation-validation.json)。桌面引擎模拟移动 UA,方向 lock 为受控接口;真实媒体播放已验证,但物理旋转、安全区域和设备方向限制仍需设备证据。
CORE-16 保持 doing;下一步处理页面全屏销毁时位置恢复失败等边界,再跑完整源码/实际安装 UMD/legacy 和消费者出口。回退恢复 autoOrientation.js、内置装配与对应生成物;保留已复现缺陷和日志。
## 第八批:剩余退出与恢复失败边界
页面全屏销毁恢复测试先跑 12 项,6 项通过、6 项失败,确认 insertBefore 在移动前抛错时两种 destroy 都会留下 body 中的播放器。现在销毁恢复失败后,只有仍在原归属之外的播放器才尝试移除,防止孤立节点;已归位再抛错则交给正常 template.destroy。原错误继续抛出,二次移除也失败时由生命周期错误集合保留两者。异常 fallback 可能使 destroy(false) 的残留节点变成脱离文档状态,这比保留错误位置的可见播放器更可控,且调用者仍收到原恢复失败;正常退出的可重试快照不变。
原生 PiP 补充过期 leave 回调复现:视频仍属于当前 PiP 时,旧逻辑错误发出 false;现在以当前归属过滤该信号,真正退出仍发 false。15 项 PiP 单元测试通过。该补充属于 BASE-DOM-12 的事件一致性,未新增公开 API。
原生 void 全屏退出补充复现:文档实际全屏的是本实例 video、原请求 target 是 player 时,旧等待条件提前结算。现在进入仍等待 target,退出由实例归属判断,直到 player/video 都不再拥有全屏;另一元素成为全屏也可完成本次退出,不会把它当作自己。19 项原生全屏单元测试通过,并加入实际分发可运行的三浏览器受控用例。该补充属于 BASE-LIFE-18 的结算边界。
正在进行最后的源码与安装包出口验证;前七批 manifest 仍代表各自历史源码,不用它们覆盖本批新增改动。
## 最终验收
- 全屏、页面全屏、video-only fallback、PiP、mini、自动尺寸、宽高比、翻转与自动方向已迁移 TS,分离能力适配、状态、几何、DOM 和资源作用域。核心 159 个与 chapter 5 个生产 TS 文件严格检查通过;公开 mixin 属性描述符、旧返回形态及正常事件/DOM 契约保持。
- yarn ci:check:269 项通过(240 单元、4 工程、25 基线)。此前源码全量 897 项通过,但它早于最后的 void video-owner 修正;最终安装包矩阵包含新用例,不把旧源码指纹冒充最终源码。
- 实际 core/chapter tarball 仓库外安装:27 项运行时、五组类型零诊断。最终安装 UMD 和 legacy 各 900 项三浏览器通过,其中各 300 项显示模块检查;无重试、跳过或未处理页面错误。
- [最终证据](../baselines/display-validation.json)核对当前全部核心源码和构建快照、三个分发格式与 tarball/docs、全部包内文件、公开类型、架构说明、22 个语言文件和独立分发许可。
- Chromium/Firefox 真实原生 PiP 窗口与播放推进通过;三引擎真实桌面 document fullscreen 通过。当前 WebKit 标准 PiP 不可用,video-only/void/WebKit PiP/方向锁为受控路径,物理 iOS/Android、安全区域、实际方向/手势等仍交 REL-03/REVIEW-02,不声称设备或发布准入已通过。
- 销毁恢复失败的异常降级为移除仍脱离归属的播放器并保留原错误;原生平台拒绝退出或 unlock 时只能 best effort。外部任意 DOM/方向 API 副作用不被承诺可回滚。
- 无新增依赖、锁文件或版本修改。公开 PiP 历史 boolean 声明差异 BASE-TYPE-10 继续由 CORE-21 协调;不将保持旧 JS Element/null 返回改为强制 boolean。
- 回退本任务时恢复对应旧门面、autoOrientation 及内置装配,移除 display 新模块/对应测试,用仓库脚本重新构建核心和 i18n;保留历史基线与缺陷记录。
关闭本任务十三个 DOM/生命周期缺陷。当前 214 项中 52 完成、162 待办;下一项 CORE-17 键盘、手势、焦点和全局事件。全生态、设备、远端 CI、多轮复盘和发布门槛继续按计划推进。本任务独立本地提交,不推送或发布。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 163 / doing 0 / blocked 0 / done 51 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 162 / doing 0 / blocked 0 / done 52 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -119,7 +119,7 @@
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值 | H | done |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | done |
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | done |
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | done |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | todo |
| CORE-18 | artplayer<br>迁移内置插件和提示模块 | CORE-14, CORE-16, CORE-17 | autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask | 默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer;接续 BASE-TYPE-07 的 notice.show boolean getter 与历史读类型冲突 | M | todo |
| CORE-19 | artplayer<br>整理进度、质量、缩略图和截图 | CORE-10, CORE-13, CORE-16 | 相关 player/control 功能与纯计算分离 | 截图跨域失败、缩略图布局、quality/进度边界保持 | M | todo |
@@ -450,6 +450,7 @@
- CORE-13: [记录](changes/2026-09-11-CORE-13-components.md) [记录](baselines/components-validation.json)
- CORE-14: [记录](changes/2026-09-11-CORE-14-setting.md) [记录](baselines/setting-validation.json)
- CORE-15: [记录](changes/2026-09-11-CORE-15-subtitle.md) [记录](baselines/subtitle-validation.json)
- CORE-16: [记录](changes/2026-09-11-CORE-16-display.md) [记录](baselines/display-validation.json)
- 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-16 显示模式完成
全屏、video-only fallback、PiP、mini、自动尺寸、宽高比、翻转和方向已迁移 TS,拆清能力、状态、几何、DOM 和资源归属。修复重入/取消/销毁、节点和样式恢复、跨实例归属、过期事件及无效尺寸;正常旧 API、返回和事件保持。
CI 269 项、核心 159 个与 chapter 5 个生产 TS 文件严格检查通过。实际安装 UMD 和 legacy 各 900 项三浏览器通过(各含 300 项显示检查);真实 tarball 27 项运行时及五组类型零诊断。源码、产物、全部包内文件、公开类型、语言与许可指纹一致。见 [交付](changes/2026-09-11-CORE-16-display.md) 和 [最终证据](baselines/display-validation.json)。
关闭十三个 DOM/生命周期缺陷。BASE-TYPE-10 公开 PiP 声明继续交 CORE-21;物理设备与受控 fallback 的限制仍保留在 REL-03/REVIEW-02。当前 214 项:52 完成、162 待办;下一项 CORE-17 键盘、手势、焦点与全局事件。本任务独立本地提交,不推送或发布。
## 当前实施:CORE-15 字幕请求、track 与渲染完成
字幕管理器拆为 7 个 TS 模块,字幕偏移 mixin 同时迁移;请求取消及时结算,过期结果停止写入;仅释放自建 URL,处理 track 重入/失败恢复、原生 cue/全屏、错误及清理。正常转换、回调、DOM、返回和事件契约保留。
+14
View File
@@ -70,3 +70,17 @@
| BASE-DOM-08 | resolved / 已复现 | Sharing a live setting object leaves a second published instance blank and can steal candidate bindings | CORE-14 |
| BASE-LIFE-15 | resolved / 已复现 | Subtitle requests overwrite newer state and mutate tracks after destroy | CORE-15 |
| BASE-LIFE-16 | resolved / 已复现 | Subtitle URLs revoke caller resources and leak the final generated resource | CORE-15 |
| BASE-DOM-09 | resolved / 已复现 | Web fullscreen loses style and placement across repeated entry or configuration changes | CORE-16 |
| BASE-LIFE-17 | resolved / 已复现 | Destroy in body web fullscreen leaves a connected orphan player | CORE-16 |
| BASE-DOM-10 | resolved / 已复现 | Native fullscreen state and exit affect unrelated player instances | CORE-16 |
| BASE-LIFE-18 | resolved / 已复现 | Native fullscreen retains destroyed listeners and leaks rejected request listeners | CORE-16 |
| BASE-DOM-11 | resolved / 已复现 | Video-only fullscreen ignores native video state and begin/end transitions | CORE-16 |
| BASE-LIFE-19 | resolved / 已复现 | Video-only fullscreen survives player destruction and late entry | CORE-16 |
| BASE-DOM-12 | resolved / 已复现 | PiP reports another video as active and WebKit ignores native presentation transitions | CORE-16 |
| BASE-LIFE-20 | resolved / 已复现 | PiP survives destruction or late entry and rejected assignments become unhandled | CORE-16 |
| BASE-TYPE-10 | open / 已复现 | PiP getter is declared boolean but native runtime returns an element or null | CORE-16, CORE-21 |
| BASE-DOM-13 | resolved / 已复现 | Mini exit loses the video's original parent and sibling position | CORE-16 |
| BASE-LIFE-21 | resolved / 已复现 | Mini leaves a connected popup after player destruction | CORE-16 |
| BASE-DOM-14 | resolved / 已复现 | Unready media sizing partially overwrites layout and emits NaN height | CORE-16 |
| BASE-DOM-15 | resolved / 已复现 | Auto orientation exit clears caller-owned inline dimensions and transforms | CORE-16 |
| BASE-LIFE-22 | resolved / 已复现 | Orientation lock success after fullscreen exit reactivates a cancelled player | CORE-16 |
+303
View File
@@ -1350,6 +1350,309 @@
"test/subtitle.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass 600 real-browser checks. Subtitle cancellation settles promptly and stale work cannot commit. Only generated URLs are owned; native fullscreen-kind rebuilds retain them and failure/reentry/destroy cleanup is verified. Runtime and type consumers pass against the exact packed source and artifacts."
},
{
"id": "BASE-DOM-09",
"title": "Web fullscreen loses style and placement across repeated entry or configuration changes",
"closureCriteria": "Final installed matrices prove original styles, parent and sibling restoration, resize integration, reentry and failure recovery.",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-web.spec.js",
"refactor/changes/2026-09-11-CORE-16-display.md"
],
"compatibleResolution": "Capture the first entry layout, restore original ownership on exit/destroy, preserve ordinary events, and stop obsolete operations after reentry.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-web.spec.js"
],
"resolutionRationale": "Session snapshots retain exact initial styles and parent/sibling positions through repeated entry, flag changes and reentry. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-LIFE-17",
"title": "Destroy in body web fullscreen leaves a connected orphan player",
"closureCriteria": "Final installed matrices prove ownership restoration for both removeHtml values and reentrant destroy without late events.",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-web.spec.js",
"refactor/changes/2026-09-11-CORE-16-display.md"
],
"compatibleResolution": "Capture the first entry layout, restore original ownership on exit/destroy, preserve ordinary events, and stop obsolete operations after reentry.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-web.spec.js"
],
"resolutionRationale": "Fullscreen body placement is restored before template destruction; a failed restore detaches only a still-displaced player while preserving the original error. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-DOM-10",
"title": "Native fullscreen state and exit affect unrelated player instances",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-native.spec.js",
"refactor/changes/2026-09-11-CORE-16-display.md"
],
"compatibleResolution": "Derive fullscreen from the instance player/video ownership, ignore unrelated changes, preserve normal event-before-class ordering, and exit only the owned element.",
"closureCriteria": "Final installed UMD/legacy matrices cover real gesture entry, two live instances, fullscreen exit rejection/retry, subtitle integration and display mode composition.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-native.spec.js"
],
"resolutionRationale": "Fullscreen ownership follows the actual instance player/video; real native tests cover two instances and mode transitions. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-LIFE-18",
"title": "Native fullscreen retains destroyed listeners and leaks rejected request listeners",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-native.spec.js",
"test/display-native.test.js",
"refactor/changes/2026-09-11-CORE-16-display.md"
],
"compatibleResolution": "Scope per-instance and per-request listeners, preserve observable native rejection identity, settle cancelled requests promptly, and release late cancelled entry without retaining destroyed instances.",
"closureCriteria": "Final installed matrices cover assignment errors, descriptor rejection, request cancellation/destroy, late Promise and controlled void APIs; document the weak document-lifetime fallback guard and physical-device limitations.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-native.spec.js",
"test/display-native.test.js"
],
"resolutionRationale": "Owned request listeners settle on success/failure/cancel, cancelled native entry is released, and void exit waits for both possible owned elements to leave. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-DOM-11",
"title": "Video-only fullscreen ignores native video state and begin/end transitions",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-video-fullscreen.spec.js",
"test/display-video-fullscreen.test.js"
],
"compatibleResolution": "Read native video presentation/display state and listen to video begin/end plus the historical document bridge, deduplicating the same transition while retaining the synchronous setter.",
"closureCriteria": "Installed candidate regressions cover state, event order, reentry, PiP distinction and exit retry; record separately the physical iOS validation gate.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-video-fullscreen.spec.js",
"test/display-video-fullscreen.test.js"
],
"resolutionRationale": "Controlled native video presentation events and state distinguish fullscreen from PiP and deduplicate transitions without changing synchronous setters. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-LIFE-19",
"title": "Video-only fullscreen survives player destruction and late entry",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-video-fullscreen.spec.js",
"test/display-video-fullscreen.test.js"
],
"compatibleResolution": "Dispose instance listeners, exit owned video presentation, and leave only a static video-held cancellation guard for unabortable late entry without retaining the instance.",
"closureCriteria": "Installed regressions cover active and pending destruction, exit failure behavior, new entry after cancellation and video shims without state properties; physical-device evidence remains explicit.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-video-fullscreen.spec.js",
"test/display-video-fullscreen.test.js"
],
"resolutionRationale": "Video-only instance listeners are removed at destruction; static video-held weak cancellation guards release late entry without holding the player scope. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-DOM-12",
"title": "PiP reports another video as active and WebKit ignores native presentation transitions",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-pip.spec.js",
"test/browser/display-pip-webkit.spec.js"
],
"compatibleResolution": "Keep native element/null and WebKit boolean return shapes, restrict native ownership, observe WebKit transitions and preserve repeated synchronous setter events.",
"closureCriteria": "Installed matrices retain getter/setter contracts and prove native gestures/playback, transition deduplication, fullscreen composition and explicit unsupported capabilities.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-pip.spec.js",
"test/browser/display-pip-webkit.spec.js"
],
"resolutionRationale": "PiP retains native Element/null and WebKit boolean getters, ignores unrelated ownership and stale leave events, and observes native UI transitions. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-LIFE-20",
"title": "PiP survives destruction or late entry and rejected assignments become unhandled",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-pip.spec.js",
"test/browser/display-pip-webkit.spec.js",
"test/display-pip.test.js"
],
"compatibleResolution": "Scope native listeners, restrict exit to owned presentation, suppress obsolete failure notices, and release late cancelled entry without instance-retaining WebKit guards.",
"closureCriteria": "Installed matrices verify destroy/cancel, rejection notices, retry, reentry and cross-instance isolation; document that the historical void setter cannot expose a rejected Promise.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-pip.spec.js",
"test/browser/display-pip-webkit.spec.js",
"test/display-pip.test.js"
],
"resolutionRationale": "PiP scope disposal exits owned presentation; late cancellation and rejected assignments are handled without stale notices or unhandled rejection. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-TYPE-10",
"title": "PiP getter is declared boolean but native runtime returns an element or null",
"confirmation": "reproduced",
"status": "open",
"owners": [
"CORE-16",
"CORE-21"
],
"evidence": [
"test/browser/display-pip.spec.js",
"test/types/display-pip.ts",
"packages/artplayer/types/player.d.ts"
],
"compatibleResolution": "Keep old legal JS return identities and historical public consumers; model the internal branch-dependent getter accurately and coordinate a compatible public declaration strategy in CORE-21.",
"closureCriteria": "Public declaration strategy and installed JS/TS consumers explicitly cover native element/null and fallback boolean without silently narrowing old legal consumption.",
"workspaceState": "CORE-16 preserves historical native Element/null and WebKit boolean runtime returns with precise internal PipProperty. The installed old public consumers still pass; public declaration coordination remains CORE-21."
},
{
"id": "BASE-DOM-13",
"title": "Mini exit loses the video's original parent and sibling position",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-mini.spec.js",
"test/display-mini.test.js"
],
"compatibleResolution": "Capture original placement once per entry session, restore it on exit/destroy, preserve popup reuse and original DOM/display contracts, and retain the snapshot for failed-exit retry.",
"closureCriteria": "Installed matrices cover placement and playback, reentry, recovery, caller-owned popups, narrow viewports and other display modes.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-mini.spec.js",
"test/display-mini.test.js"
],
"resolutionRationale": "Mini restores original parent/sibling snapshots and retains legacy first/reused DOM order, failure retry and caller-owned popup identity. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-LIFE-21",
"title": "Mini leaves a connected popup after player destruction",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-mini.spec.js"
],
"compatibleResolution": "Own created popup and subscriptions in the instance scope, stop drag on hide/destroy, preserve caller-owned popup nodes, and serialize reentrant view creation.",
"closureCriteria": "Installed matrices prove owned popup removal for both destroy modes, no late drag writes, active playback/controls, construction failure/abort recovery and at most one popup during reentry.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-mini.spec.js"
],
"resolutionRationale": "Owned mini popup and subscriptions are removed at destruction; hiding cancels drag and reentrant creation yields at most one popup. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-DOM-14",
"title": "Unready media sizing partially overwrites layout and emits NaN height",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-sizing.spec.js",
"test/display-sizing.test.js",
"refactor/baselines/display-sizing-validation.json"
],
"compatibleResolution": "Defer unusable geometry without invalid CSS or measurement events; recover on the next valid call while preserving normal sizing, descriptors, ratio parsing and event contracts.",
"closureCriteria": "Installed matrices cover unready/hidden geometry, recovery, ratio/flip contracts, destruction and orientation composition.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-sizing.spec.js",
"test/display-sizing.test.js"
],
"resolutionRationale": "Unusable media/container dimensions defer layout and invalid measurements; normal geometry, later recovery, ratio/flip and destruction contracts are verified. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-DOM-15",
"title": "Auto orientation exit clears caller-owned inline dimensions and transforms",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-orientation.spec.js",
"refactor/baselines/display-orientation-validation.json"
],
"compatibleResolution": "Own rotation properties and coordinate restoration with fullscreenWeb; retain initial snapshots, repeated rotated dimensions and normal resize behavior.",
"closureCriteria": "Installed matrices cover exit/destroy, repeated entry, rotated playback and ratio/flip composition; document device rotation and safe-area evidence limitations.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-orientation.spec.js"
],
"resolutionRationale": "Rotation and fullscreen restoration preserve caller-owned inline geometry; repeated entry, both destruction modes and real rotated playback/ratio/flip are verified. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-LIFE-22",
"title": "Orientation lock success after fullscreen exit reactivates a cancelled player",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-16"
],
"evidence": [
"test/browser/display-orientation.spec.js",
"test/display-orientation.test.js",
"refactor/baselines/display-orientation-validation.json"
],
"compatibleResolution": "Scope rotation timers and subscriptions, cancel locks on exit/destroy, release cancelled late success, and protect newer ArtPlayer requests with per-platform ownership tokens.",
"closureCriteria": "Installed matrices and unit tests cover timer cancellation, late success/rejection, repeated entry, retry, destruction and cross-instance ownership; physical orientation remains an explicit device gate.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-16-display.md",
"refactor/baselines/display-validation.json",
"test/browser/display-orientation.spec.js",
"test/display-orientation.test.js"
],
"resolutionRationale": "Owned timers and subscriptions cancel cleanly; orientation request tokens prevent stale classes/notices and old-instance unlock of newer ArtPlayer locks. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
}
]
}
+5 -2
View File
@@ -1049,11 +1049,14 @@
"CORE-11",
"CORE-12"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "显示模式服务和原公开属性门面",
"acceptance": "模式互斥/恢复位置、手势权限、移动方向和退出失败通过",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-16-display.md",
"baselines/display-validation.json"
]
},
{
"id": "CORE-17",
+285
View File
@@ -0,0 +1,285 @@
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')
window.miniEvents = []
window.art.on('mini', value => window.miniEvents.push(value))
const video = window.art.template.$video
const parent = document.createElement('div')
parent.id = 'original-video-parent'
const next = document.createElement('span')
next.id = 'original-video-next'
video.before(parent)
parent.append(video, next)
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: mini retains first-entry and reuse DOM order and display`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.mini = true
const popup = art.template.$mini
const first = popup.firstChild === art.template.$video
const display = popup.style.display
art.mini = true
const result = { first, display, last: popup.lastChild === art.template.$video, reusedDisplay: popup.style.display }
art.destroy()
return result
})).toEqual({ first: true, display: '', last: true, reusedDisplay: 'flex' })
})
test(`${core}: mini restores the original video parent and sibling after repeated entry`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.mini = true
const popup = art.template.$mini
art.mini = true
art.mini = false
const result = { restored: art.template.$video.parentElement.id === 'original-video-parent', next: art.template.$video.nextSibling?.id === 'original-video-next', reused: popup === art.template.$mini, hidden: popup.style.display, events: [...window.miniEvents] }
art.destroy()
return result
})).toEqual({ restored: core === 'candidate', next: core === 'candidate', reused: true, hidden: 'none', events: [true, true, false] })
})
for (const removeHtml of [false, true]) {
test(`${core}: destroying active mini removeHtml=${removeHtml} removes the owned popup`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate((removeHtml) => {
const art = window.art
art.mini = true
const popup = art.template.$mini
const video = art.template.$video
window.miniEvents.length = 0
art.destroy(removeHtml)
return { popupConnected: popup.isConnected, videoInPopup: popup.contains(video), events: window.miniEvents }
}, removeHtml)).toEqual({ popupConnected: core === 'published', videoInPopup: core === 'published', events: [] })
})
}
}
test('candidate: hiding mini cancels drag and later document events cannot persist a hidden popup', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
art.mini = true
const popup = art.template.$mini
const original = { left: art.storage.get('left'), top: art.storage.get('top') }
popup.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 20, clientY: 20 }))
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 40, clientY: 50 }))
art.mini = false
art.emit('document:mouseup', new MouseEvent('mouseup'))
const result = { original, after: { left: art.storage.get('left'), top: art.storage.get('top') }, dragging: popup.classList.contains('art-mini-dragging'), transform: popup.style.transform }
art.destroy()
return result
})
expect(result.after).toEqual(result.original)
expect(result.dragging).toBe(false)
expect(result.transform).toBe('')
})
test('candidate: mini remains in the viewport on small screens', async ({ page }) => {
await page.setViewportSize({ width: 280, height: 160 })
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.storage.set('left', 9000)
window.art.storage.set('top', 9000)
window.art.mini = true
})
const rect = await page.locator('.art-mini-popup').boundingBox()
expect(rect.x).toBeGreaterThanOrEqual(0)
expect(rect.y).toBeGreaterThanOrEqual(0)
expect(rect.x + rect.width).toBeLessThanOrEqual(280)
expect(rect.y + rect.height).toBeLessThanOrEqual(160)
await page.evaluate(() => window.art.destroy())
})
test('candidate: mini drag stores the actual position and playback buttons remain usable', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => window.art.mini = true)
const popup = page.locator('.art-mini-popup')
const rect = await popup.boundingBox()
await page.mouse.move(rect.x + 30, rect.y + 20)
await page.mouse.down()
await page.mouse.move(rect.x - 30, rect.y - 20)
await page.mouse.up()
expect(await page.evaluate(() => {
const art = window.art
const rect = art.template.$mini.getBoundingClientRect()
return Math.abs(art.storage.get('left') - rect.left) < 1 && Math.abs(art.storage.get('top') - rect.top) < 1
})).toBe(true)
await popup.locator('.art-icon-play').click()
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0)).toBe(true)
await popup.locator('.art-icon-pause').click()
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused)).toBe(true)
await popup.locator('.art-mini-close').click()
await expect(popup).toBeHidden()
expect(await page.evaluate(() => window.art.template.$video.parentElement.id)).toBe('original-video-parent')
await page.evaluate(() => window.art.destroy())
})
test('candidate: mini exit failure keeps the active video and can restore on retry', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const parent = art.template.$video.parentNode
art.mini = true
const insert = parent.insertBefore
const failure = new Error('controlled mini restore failure')
parent.insertBefore = () => {
throw failure
}
let rejected = false
try {
art.mini = false
}
catch (error) { rejected = error === failure }
const active = art.mini && art.template.$mini.contains(art.template.$video)
parent.insertBefore = insert
art.mini = false
const result = { rejected, active, restored: art.template.$video.parentNode === parent }
art.destroy()
return result
})).toEqual({ rejected: true, active: true, restored: true })
})
test('candidate: mini reentry during restoration retains the new placement snapshot', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const parent = art.template.$video.parentNode
const insert = parent.insertBefore
art.mini = true
parent.insertBefore = function (...args) {
const result = insert.apply(this, args)
parent.insertBefore = insert
art.mini = true
return result
}
art.mini = false
const active = art.mini
art.mini = false
const result = { active, restored: art.template.$video.parentNode === parent, events: [...window.miniEvents] }
art.destroy()
return result
})).toEqual({ active: true, restored: true, events: [true, true, false] })
})
for (const action of ['hide', 'destroy', 'fail']) {
test(`candidate: mini construction ${action} releases incomplete UI and restores the video`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const icons = art.icons
const failure = new Error('controlled mini icon failure')
art.icons = {
get close() {
if (action === 'hide')
art.mini = false
else if (action === 'destroy')
art.destroy(false)
else
throw failure
return icons.close
},
get play() { return icons.play },
get pause() { return icons.pause },
}
let rejected = false
try {
art.mini = true
}
catch (error) {
rejected = error === failure
}
const restored = art.template.$video.parentElement.id === 'original-video-parent'
const active = art.mini
let retry = null
if (action !== 'destroy') {
art.icons = icons
art.mini = true
retry = art.mini && document.querySelectorAll('.art-mini-popup').length === 1
art.destroy()
}
return { restored, active, rejected, retry, remaining: document.querySelectorAll('.art-mini-popup').length }
}, action)).toEqual({ restored: true, active: false, rejected: action === 'fail', retry: action === 'destroy' ? null : true, remaining: 0 })
})
}
test('candidate: mini preserves a caller-owned popup on destruction', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const popup = document.createElement('div')
popup.style.cssText = 'display: grid; width: 320px; height: 180px;'
document.body.append(popup)
art.template.$mini = popup
art.mini = true
art.destroy(false)
const result = { connected: popup.isConnected, display: popup.style.display, restored: art.template.$video.parentElement.id === 'original-video-parent', empty: popup.childNodes.length === 0 }
popup.remove()
return result
})).toEqual({ connected: true, display: 'grid', restored: true, empty: true })
})
test('candidate: reentrant mini creation allocates only one owned popup', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const icons = art.icons
let reentered = false
art.icons = {
get close() {
if (!reentered) {
reentered = true
art.mini = true
}
return icons.close
},
get play() { return icons.play },
get pause() { return icons.pause },
}
art.mini = true
const result = { popups: document.querySelectorAll('.art-mini-popup').length, ownVideo: art.template.$mini.contains(art.template.$video), events: [...window.miniEvents] }
art.destroy()
return result
})).toEqual({ popups: 1, ownVideo: true, events: [true] })
})
test('candidate: mini switches to body web fullscreen and restores the original video placement', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
window.Artplayer.FULLSCREEN_WEB_IN_BODY = true
art.mini = true
art.fullscreenWeb = true
const switched = !art.mini && art.fullscreenWeb && art.template.$mini.style.display === 'none'
art.fullscreenWeb = false
const restored = art.template.$video.parentElement.id === 'original-video-parent'
art.mini = true
art.mini = false
const events = [...window.miniEvents]
art.destroy()
return { switched, restored, events }
})).toEqual({ switched: true, restored: true, events: [true, false, true, false] })
})
test('candidate: destroying a mini drag removes its document subscriptions', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.mini = true
art.template.$mini.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 10, clientY: 10 }))
art.destroy(false)
let writes = 0
art.storage.set = () => writes++
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 100, clientY: 100 }))
art.emit('document:mouseup', new MouseEvent('mouseup'))
return writes
})).toBe(0)
})
+251
View File
@@ -0,0 +1,251 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const maps = [
['requestFullscreen', 'exitFullscreen', 'fullscreenElement', 'fullscreenchange'],
['webkitRequestFullscreen', 'webkitExitFullscreen', 'webkitFullscreenElement', 'webkitfullscreenchange'],
['webkitRequestFullScreen', 'webkitCancelFullScreen', 'webkitCurrentFullScreenElement', 'webkitfullscreenchange'],
['mozRequestFullScreen', 'mozCancelFullScreen', 'mozFullScreenElement', 'mozfullscreenchange'],
['msRequestFullscreen', 'msExitFullscreen', 'msFullscreenElement', 'MSFullscreenChange'],
]
window.fullscreenAPI = maps.find(([, exit]) => exit in document)
window.createPlayer('/test/pattern.mp4')
window.art.template.$container.id = 'native-fullscreen-main'
const container = document.createElement('div')
Object.assign(container.style, { width: '320px', height: '180px' })
document.body.append(container)
window.other = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
window.fullscreenEvents = [[], []]
window.art.on('fullscreen', value => window.fullscreenEvents[0].push(value))
window.other.on('fullscreen', value => window.fullscreenEvents[1].push(value))
document.querySelector('#play').onclick = () => {
window.art.fullscreen = true
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady && window.other.isReady)).toBe(true)
expect(await page.evaluate(() => Boolean(window.fullscreenAPI))).toBe(true)
}
test('candidate: void exit waits for the owned video to leave and accepts another owner as completion', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async () => {
const art = window.art
const [, exitName, elementName, eventName] = window.fullscreenAPI
const elementDescriptor = Object.getOwnPropertyDescriptor(document, elementName)
const originalExit = document[exitName]
let element = art.template.$video
Object.defineProperty(document, elementName, { configurable: true, get: () => element })
document.dispatchEvent(new Event(eventName))
document[exitName] = () => undefined
let settled = false
const result = Object.getOwnPropertyDescriptor(art, 'fullscreen').set(false).then(() => settled = true)
await new Promise(resolve => setTimeout(resolve, 0))
const early = settled
element = window.other.template.$player
document.dispatchEvent(new Event(eventName))
await result
const completed = settled && !art.fullscreen && window.other.fullscreen
element = null
document.dispatchEvent(new Event(eventName))
if (elementDescriptor)
Object.defineProperty(document, elementName, elementDescriptor)
else
delete document[elementName]
document[exitName] = originalExit
art.destroy()
window.other.destroy()
return { early, completed }
})).toEqual({ early: false, completed: true })
})
for (const core of ['published', 'candidate']) {
test(`${core}: native fullscreen belongs to its player and other players cannot exit it`, async ({ page }) => {
await setup(page, core)
await page.click('#play')
await expect.poll(() => page.evaluate(() => document[window.fullscreenAPI[2]] === window.art.template.$player)).toBe(true)
await expect.poll(() => page.evaluate(() => window.fullscreenEvents[0])).toEqual([true])
expect(await page.evaluate(() => ({ own: window.art.fullscreen, other: window.other.fullscreen, className: window.other.template.$player.classList.contains('art-fullscreen'), otherEvents: window.fullscreenEvents[1] }))).toEqual({ own: true, other: core === 'published', className: core === 'published', otherEvents: core === 'published' ? [true] : [] })
await page.evaluate(async () => {
await Object.getOwnPropertyDescriptor(window.other, 'fullscreen').set.call(window.other, false)
})
await expect.poll(() => page.evaluate(() => Boolean(document[window.fullscreenAPI[2]]))).toBe(core === 'candidate')
await page.evaluate(async () => {
if (document[window.fullscreenAPI[2]])
await document[window.fullscreenAPI[1]]()
window.art.destroy()
window.other.destroy()
})
})
test(`${core}: destroyed players stop receiving later native fullscreen events`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.other.destroy(false))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.fullscreenEvents[0])).toEqual([true])
expect(await page.evaluate(() => ({ events: window.fullscreenEvents[1], className: window.other.template.$player.classList.contains('art-fullscreen') }))).toEqual({ events: core === 'published' ? [true] : [], className: core === 'published' })
await page.evaluate(async () => {
await document[window.fullscreenAPI[1]]()
window.art.destroy()
})
})
test(`${core}: rejected native fullscreen request removes its temporary change listener`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
const art = window.art
const [request, , , change] = window.fullscreenAPI
const add = document.addEventListener
const remove = document.removeEventListener
const active = new Set()
document.addEventListener = function (name, callback, options) {
if (name === change)
active.add(callback)
return add.call(this, name, callback, options)
}
document.removeEventListener = function (name, callback, options) {
if (name === change)
active.delete(callback)
return remove.call(this, name, callback, options)
}
const failure = new Error('controlled fullscreen rejection')
art.template.$player[request] = () => Promise.reject(failure)
const rejected = await Object.getOwnPropertyDescriptor(art, 'fullscreen').set.call(art, true).then(() => false, error => error === failure)
const result = { rejected, leaked: active.size }
document.addEventListener = add
document.removeEventListener = remove
art.destroy()
window.other.destroy()
return result
})).toEqual({ rejected: true, leaked: core === 'published' ? 1 : 0 })
})
}
test('candidate: fullscreen assignment rejection shows notice without an unhandled rejection', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const [request] = window.fullscreenAPI
window.art.template.$player[request] = () => Promise.reject(new Error('controlled assignment denial'))
window.art.fullscreen = true
})
await expect(page.locator('#native-fullscreen-main .art-notice')).toContainText('controlled assignment denial')
await page.evaluate(() => {
window.art.destroy()
window.other.destroy()
})
})
test('candidate: reentrant entry preserves real fullscreen UI and event order', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.fullscreenOrder = []
window.art.on('fullscreen', (value) => {
if (value) {
window.fullscreenOrder.push(['fullscreen', window.art.template.$player.classList.contains('art-fullscreen')])
window.art.fullscreen = true
}
})
window.art.on('resize', () => {
if (window.art.fullscreen)
window.fullscreenOrder.push(['resize', window.art.template.$player.classList.contains('art-fullscreen')])
})
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.fullscreen && window.art.template.$player.classList.contains('art-fullscreen'))).toBe(true)
expect(await page.evaluate(() => window.fullscreenOrder.slice(0, 2))).toEqual([['fullscreen', false], ['resize', true]])
await page.evaluate(async () => {
await document[window.fullscreenAPI[1]]()
window.art.destroy()
window.other.destroy()
})
})
for (const mode of ['promise-exit', 'promise-destroy', 'void-exit', 'void-destroy']) {
test(`candidate: pending native entry ${mode} releases a late own entry`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async (mode) => {
const art = window.art
const [request, exit, element, change] = window.fullscreenAPI
const originalExit = document[exit]
const originalElement = Object.getOwnPropertyDescriptor(document, element)
let current = null
let complete
let exits = 0
Object.defineProperty(document, element, { configurable: true, get: () => current })
art.template.$player[request] = () => {
if (mode.startsWith('promise'))
return new Promise(resolve => complete = resolve)
complete = () => document.dispatchEvent(new Event(change))
}
document[exit] = () => {
exits++
current = null
document.dispatchEvent(new Event(change))
return Promise.resolve()
}
const setter = Object.getOwnPropertyDescriptor(art, 'fullscreen').set
const pending = setter.call(art, true)
if (mode.endsWith('destroy'))
art.destroy(false)
else
await setter.call(art, false)
await pending
current = art.template.$player
complete()
// Flush the request's native Promise observer and setter settlement chain.
await new Promise(resolve => setTimeout(resolve, 0))
const result = { exits, fullscreen: art.fullscreen, className: art.template.$player.classList.contains('art-fullscreen'), events: window.fullscreenEvents[0] }
art.destroy()
window.other.destroy()
document[exit] = originalExit
if (originalElement)
Object.defineProperty(document, element, originalElement)
else
delete document[element]
return result
}, mode)).toEqual({ exits: 1, fullscreen: false, className: false, events: [] })
})
}
test('candidate: cancelling an actual native request in the initiating click releases fullscreen', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.entrySettled = false
document.querySelector('#play').onclick = () => {
const setter = Object.getOwnPropertyDescriptor(window.art, 'fullscreen').set
const entering = setter.call(window.art, true)
window.art.fullscreen = false
void entering.then(() => window.entrySettled = true)
}
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.entrySettled && !document[window.fullscreenAPI[2]])).toBe(true)
await page.evaluate(() => {
window.art.destroy()
window.other.destroy()
})
})
test('candidate: rejected exit retains actual fullscreen state and can be retried', async ({ page }) => {
await setup(page, 'candidate')
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.template.$player.classList.contains('art-fullscreen'))).toBe(true)
expect(await page.evaluate(async () => {
const [, exit] = window.fullscreenAPI
const originalExit = document[exit]
const failure = new Error('controlled exit denial')
document[exit] = () => Promise.reject(failure)
const setter = Object.getOwnPropertyDescriptor(window.art, 'fullscreen').set
const rejected = await setter.call(window.art, false).then(() => false, error => error === failure)
const result = { rejected, fullscreen: window.art.fullscreen, className: window.art.template.$player.classList.contains('art-fullscreen') }
document[exit] = originalExit
await setter.call(window.art, false)
return result
})).toEqual({ rejected: true, fullscreen: true, className: true })
await expect.poll(() => page.evaluate(() => window.art.fullscreen || window.art.template.$player.classList.contains('art-fullscreen'))).toBe(false)
await page.evaluate(() => {
window.art.destroy()
window.other.destroy()
})
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.setViewportSize({ width: 400, height: 800 })
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android orientation fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.Artplayer.AUTO_ORIENTATION_TIME = 10
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, autoOrientation: true })
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate: a cancelled rotation timer cannot rotate a later fullscreen session early', async ({ page }) => {
await setup(page, 'candidate')
await page.clock.install({ time: new Date('2026-09-11T00:00:00Z') })
await page.clock.pauseAt(new Date('2026-09-11T00:00:01Z'))
await page.evaluate(() => {
window.Artplayer.AUTO_ORIENTATION_TIME = 1000
window.art.fullscreenWeb = true
})
await page.clock.runFor(700)
await page.evaluate(() => {
window.art.fullscreenWeb = false
window.art.fullscreenWeb = true
})
await page.clock.runFor(400)
expect(await page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(false)
await page.clock.runFor(600)
expect(await page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
for (const removeHtml of [false, true]) {
test(`candidate: rotation destruction restores styles and cancels subscriptions removeHtml=${removeHtml}`, async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
const art = window.art
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.template.$player.style.cssText = 'width:75%;height:80%;transform:scale(0.9);transform-origin:center;'
window.beforeRotation = art.template.$player.getAttribute('style')
art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
expect(await page.evaluate((remove) => {
const art = window.art
const player = art.template.$player
art.destroy(remove)
const style = player.getAttribute('style')
art.emit('fullscreenWeb', true)
art.emit('fullscreen', true)
return { restored: style === window.beforeRotation, rotated: art.isRotate, state: art.plugins.autoOrientation.state, retained: player.isConnected, changed: style !== player.getAttribute('style') }
}, removeHtml)).toEqual({ restored: true, rotated: false, state: false, retained: !removeHtml, changed: false })
})
}
test('candidate: destroying a pending rotation removes its timer and produces no late resize', async ({ page }) => {
await setup(page, 'candidate')
await page.clock.install({ time: new Date('2026-09-11T00:00:00Z') })
await page.clock.pauseAt(new Date('2026-09-11T00:00:01Z'))
await page.evaluate(() => {
const art = window.art
art.constructor.AUTO_ORIENTATION_TIME = 1000
art.fullscreenWeb = true
art.destroy(false)
window.lateRotations = []
art.on('resize', () => window.lateRotations.push(art.isRotate))
})
await page.clock.runFor(2000)
expect(await page.evaluate(() => ({ rotated: window.art.plugins.autoOrientation.state, events: window.lateRotations }))).toEqual({ rotated: false, events: [] })
})
test('candidate: rotated playback supports ratio and flip changes before restoring normal layout', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(async () => {
await window.art.play()
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state && window.art.currentTime > 0.1)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
art.aspectRatio = '4:3'
art.flip = 'horizontal'
const video = art.template.$video
const ratio = video.clientWidth / video.clientHeight
const flipped = art.flip
art.fullscreenWeb = false
art.aspectRatio = 'default'
art.flip = 'normal'
const result = { ratioClose: Math.abs(ratio - 4 / 3) < 0.01, flipped, playing: art.playing, rotate: art.isRotate, cleared: video.style.width === '' && video.style.height === '' }
art.destroy()
return result
})).toEqual({ ratioClose: true, flipped: 'horizontal', playing: true, rotate: false, cleared: true })
})
test('candidate: repeated web fullscreen retains rotated dimensions and final styles', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
art.fullscreenWeb = true
const result = { width: art.template.$player.style.width, height: art.template.$player.style.height, state: art.plugins.autoOrientation.state }
art.destroy()
return result
})).toEqual({ width: '800px', height: '400px', state: true })
})
for (const core of ['published', 'candidate']) {
test(`${core}: rotated web fullscreen restores original inline geometry`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
const art = window.art
art.template.$player.style.cssText = 'width:75%;height:80%;transform:scale(0.9);transform-origin:center;'
window.beforeRotation = art.template.$player.getAttribute('style')
art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.isRotate)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
const rotated = { state: art.plugins.autoOrientation.state, width: art.template.$player.style.width, height: art.template.$player.style.height }
art.fullscreenWeb = false
const original = document.createElement('div')
original.setAttribute('style', window.beforeRotation)
const restored = ['width', 'height', 'transform', 'transform-origin'].every(name => art.template.$player.style.getPropertyValue(name) === original.style.getPropertyValue(name))
const result = { rotated, restored, state: art.plugins.autoOrientation.state, isRotate: art.isRotate }
art.destroy()
return result
})).toEqual({ rotated: { state: true, width: '800px', height: '400px' }, restored: core === 'candidate', state: false, isRotate: false })
})
test(`${core}: late orientation lock after fullscreen exit does not reactivate the player`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
const art = window.art
const calls = []
let resolveLock
Object.defineProperty(screen, 'orientation', { configurable: true, value: {
type: 'portrait-primary',
lock(mode) {
calls.push(mode)
return new Promise((resolve) => {
resolveLock = resolve
})
},
unlock() {
calls.push('unlock')
},
} })
art.emit('fullscreen', true)
art.emit('fullscreen', false)
resolveLock()
await new Promise(resolve => setTimeout(resolve, 0))
const result = { requested: calls[0], unlocked: calls.includes('unlock'), active: art.template.$player.classList.contains('art-auto-orientation-fullscreen') }
art.destroy()
return result
})).toEqual({ requested: 'landscape', unlocked: core === 'candidate', active: core !== 'candidate' })
})
}
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => {
Object.defineProperty(document, 'pictureInPictureEnabled', { configurable: true, value: false })
const modes = new WeakMap()
Object.defineProperties(HTMLVideoElement.prototype, {
webkitSupportsPresentationMode: { configurable: true, writable: true, value: () => true },
webkitPresentationMode: { configurable: true, get() { return modes.get(this) || 'inline' } },
webkitSetPresentationMode: { configurable: true, writable: true, value(mode) {
modes.set(this, mode)
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
this.dispatchEvent(new Event(mode === 'picture-in-picture' ? 'enterpictureinpicture' : 'leavepictureinpicture'))
} },
})
})
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.pipEvents = []
window.art.on('pip', value => window.pipEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: WebKit PiP keeps boolean state and repeated synchronous setter events`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.pip = true
art.pip = true
const entered = art.pip
art.pip = false
const result = { entered, exited: art.pip, events: [...window.pipEvents] }
art.destroy()
return result
})).toEqual({ entered: true, exited: false, events: [true, true, false] })
})
test(`${core}: WebKit PiP follows native UI transitions`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const video = window.art.template.$video
video.webkitSetPresentationMode('picture-in-picture')
video.webkitSetPresentationMode('inline')
const result = [...window.pipEvents]
window.art.destroy()
return result
})).toEqual(core === 'candidate' ? [true, false] : [])
})
test(`${core}: destroying WebKit PiP returns presentation to inline`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
window.art.pip = true
window.pipEvents.length = 0
window.art.destroy(false)
return { mode: window.art.template.$video.webkitPresentationMode, events: window.pipEvents }
})).toEqual({ mode: core === 'candidate' ? 'inline' : 'picture-in-picture', events: [] })
})
}
test('candidate: WebKit PiP pending entry is cleaned up after destruction', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
const setMode = video.webkitSetPresentationMode.bind(video)
video.webkitSetPresentationMode = () => {}
art.pip = true
art.destroy(false)
video.webkitSetPresentationMode = setMode
setMode('picture-in-picture')
return { mode: video.webkitPresentationMode, events: window.pipEvents }
})).toEqual({ mode: 'inline', events: [] })
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => {
window.pipElement = null
window.pipExits = 0
Object.defineProperties(document, {
pictureInPictureEnabled: { configurable: true, value: true },
pictureInPictureElement: { configurable: true, get: () => window.pipElement },
exitPictureInPicture: { configurable: true, writable: true, value() {
window.pipExits++
const previous = window.pipElement
window.pipElement = null
previous?.dispatchEvent(new Event('leavepictureinpicture'))
return Promise.resolve()
} },
})
Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', {
configurable: true,
writable: true,
value() {
window.pipElement = this
this.dispatchEvent(new Event('enterpictureinpicture'))
return Promise.resolve({ width: 320, height: 180 })
},
})
})
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.pipEvents = []
window.art.on('pip', value => window.pipEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: native PiP preserves element/null getter, void setter and boolean events`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const setter = Object.getOwnPropertyDescriptor(art, 'pip').set
const entered = setter.call(art, true)
const ownElement = art.pip === art.template.$video
const exited = setter.call(art, false)
const result = { ownElement, empty: art.pip === null, entered: typeof entered, exited: typeof exited, events: [...window.pipEvents] }
art.destroy()
return result
})).toEqual({ ownElement: true, empty: true, entered: 'undefined', exited: 'undefined', events: [true, false] })
})
test(`${core}: a player cannot report or exit another video's PiP`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const other = document.createElement('video')
window.pipElement = other
const reported = window.art.pip === other
window.art.pip = false
const result = { reported, stillOther: window.pipElement === other, exits: window.pipExits }
window.pipElement = null
window.art.destroy()
return result
})).toEqual({ reported: core === 'published', stillOther: core === 'candidate', exits: core === 'published' ? 1 : 0 })
})
test(`${core}: destroy releases active PiP without a late player event`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
window.art.pip = true
await Promise.resolve()
window.pipEvents.length = 0
window.art.destroy(false)
await Promise.resolve()
return { active: Boolean(window.pipElement), events: window.pipEvents }
})).toEqual({ active: core === 'published', events: [] })
})
}
for (const action of ['cancel', 'destroy']) {
test(`candidate: pending PiP ${action} releases late native entry`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async (action) => {
const art = window.art
const video = art.template.$video
let complete
video.requestPictureInPicture = () => new Promise(resolve => complete = resolve)
art.pip = true
if (action === 'destroy')
art.destroy(false)
else
art.pip = false
window.pipElement = video
video.dispatchEvent(new Event('enterpictureinpicture'))
complete({ width: 320, height: 180 })
await new Promise(resolve => setTimeout(resolve, 0))
const result = { active: Boolean(window.pipElement), events: [...window.pipEvents] }
art.destroy()
return result
}, action)).toEqual({ active: false, events: [] })
})
}
test('candidate: rejected PiP assignment shows notice without an unhandled rejection', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.template.$video.requestPictureInPicture = () => Promise.reject(new Error('controlled PiP denial'))
window.art.pip = true
})
await expect(page.locator('.art-notice')).toContainText('controlled PiP denial')
await page.evaluate(() => window.art.destroy())
})
test('candidate: actual native PiP gesture and playback or explicit unsupported capability', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const capability = await page.evaluate(() => ({
enabled: document.pictureInPictureEnabled === true,
request: typeof window.art.template.$video.requestPictureInPicture === 'function',
exit: typeof document.exitPictureInPicture === 'function',
}))
await testInfo.attach('native-pip-capability', { contentType: 'application/json', body: JSON.stringify(capability) })
if (!capability.enabled || !capability.request || !capability.exit) {
expect(await page.evaluate(() => window.art.pip)).toBe(false)
await page.evaluate(() => window.art.destroy())
return
}
await page.evaluate(() => {
const video = window.art.template.$video
const request = video.requestPictureInPicture.bind(video)
video.requestPictureInPicture = () => {
const result = request()
void result.then(pipWindow => window.actualPipWindow = { width: pipWindow.width, height: pipWindow.height })
return result
}
document.querySelector('#play').onclick = () => {
void window.art.play()
window.art.pip = true
}
})
await page.evaluate(() => window.art.mini = true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.pip === window.art.template.$video)).toBe(true)
expect(await page.evaluate(() => window.art.mini)).toBe(false)
await expect.poll(() => page.evaluate(() => window.actualPipWindow?.width > 0 && window.art.currentTime > 0 && !window.art.template.$video.paused)).toBe(true)
await testInfo.attach('native-pip-playback', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ window: window.actualPipWindow, currentTime: window.art.currentTime, events: window.events }))) })
await page.evaluate(() => window.art.fullscreenWeb = true)
await expect.poll(() => page.evaluate(() => document.pictureInPictureElement)).toBe(null)
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
await page.evaluate(() => window.art.fullscreenWeb = false)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.pip === window.art.template.$video)).toBe(true)
await page.evaluate(() => {
document.querySelector('#play').onclick = () => window.art.fullscreen = true
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.fullscreen && !document.pictureInPictureElement)).toBe(true)
await page.evaluate(() => window.art.fullscreen = false)
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(false)
await page.evaluate(() => window.art.destroy())
})
+161
View File
@@ -0,0 +1,161 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: sizing methods remain detachable own properties and ratio parsing retains legacy coercion`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const descriptors = ['autoSize', 'autoHeight', 'aspectRatio', 'flip'].map((name) => {
const descriptor = Object.getOwnPropertyDescriptor(art, name)
return { name, configurable: descriptor.configurable, enumerable: descriptor.enumerable, writable: descriptor.writable ?? null }
})
const { autoSize, autoHeight } = art
const returns = [typeof Reflect.apply(autoSize, null, []), typeof Reflect.apply(autoHeight, {}, [])]
art.template.$player.style.cssText = 'width:640px;height:360px;'
const events = []
art.on('aspectRatio', value => events.push(value))
art.aspectRatio = ' 4 : 3 : ignored'
const parsed = { value: art.aspectRatio, width: art.template.$video.style.width, height: art.template.$video.style.height }
art.aspectRatio = ''
art.aspectRatio = 'default'
art.flip = 'custom'
const custom = art.flip
art.flip = ''
const result = { descriptors, returns, parsed, events, custom, reset: art.flip }
art.destroy()
return result
})).toEqual({
descriptors: ['autoSize', 'autoHeight', 'aspectRatio', 'flip'].map(name => ({ name, configurable: false, enumerable: false, writable: name.startsWith('auto') ? false : null })),
returns: ['undefined', 'undefined'],
parsed: { value: ' 4 : 3 : ignored', width: '480px', height: '100%' },
events: [' 4 : 3 : ignored', 'default', 'default'],
custom: 'custom',
reset: 'normal',
})
})
}
test('candidate: hidden containers defer sizing and recover after becoming visible', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $container, $video, $player } = art.template
Object.defineProperties($video, { videoWidth: { configurable: true, value: 1920 }, videoHeight: { configurable: true, value: 1080 } })
$container.style.cssText = 'display:none;width:640px;height:480px;'
$player.style.cssText = 'width:80%;height:70%;'
const events = []
art.on('autoSize', value => events.push(['size', value]))
art.on('autoHeight', value => events.push(['height', value]))
art.autoSize()
art.autoHeight()
const hidden = { width: $player.style.width, height: $player.style.height, containerHeight: $container.style.height, count: events.length }
$container.style.display = 'block'
art.autoSize()
art.autoHeight()
const result = { hidden, events, height: $container.style.height }
art.destroy()
return result
})).toEqual({ hidden: { width: '80%', height: '70%', containerHeight: '480px', count: 0 }, events: [['size', { width: 640, height: 360 }], ['height', 360]], height: '360px' })
})
test('candidate: malformed ratios preserve layout while retaining notices, datasets and events', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $video } = art.template
art.aspectRatio = '4:3'
const before = $video.getAttribute('style')
const values = []
art.on('aspectRatio', value => values.push(value))
const stable = ['bad', '0:0', '-1:2', '1:0', 'Infinity:2'].map((value) => {
art.aspectRatio = value
return $video.getAttribute('style') === before && art.aspectRatio === value && art.template.$notice.textContent.includes(value)
})
art.destroy()
return { stable, values }
})).toEqual({ stable: [true, true, true, true, true], values: ['bad', '0:0', '-1:2', '1:0', 'Infinity:2'] })
})
test('candidate: retained display setters and sizing methods do not write or emit after destroy', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $container, $player, $video } = art.template
art.destroy(false)
const before = [$container.outerHTML, $player.outerHTML, $video.outerHTML]
const events = []
for (const name of ['autoSize', 'autoHeight', 'aspectRatio', 'flip'])
art.on(name, () => events.push(name))
art.autoSize()
art.autoHeight()
art.aspectRatio = '4:3'
art.flip = 'horizontal'
return { same: before.every((html, index) => html === [$container, $player, $video][index].outerHTML), events }
})).toEqual({ same: true, events: [] })
})
for (const core of ['published', 'candidate']) {
test(`${core}: sizing preserves valid geometry, return values and emitted measurements`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const { $container, $player, $video } = art.template
Object.defineProperties($video, { videoWidth: { configurable: true, value: 1920 }, videoHeight: { configurable: true, value: 1080 } })
$container.style.cssText = 'width:640px;height:480px;'
const sizes = []
const heights = []
art.on('autoSize', value => sizes.push(value))
art.on('autoHeight', value => heights.push(value))
const autoSize = art.autoSize()
const styles = { width: $player.style.width, height: $player.style.height }
const autoHeight = art.autoHeight()
const result = { styles, sizes, heights, returns: [typeof autoSize, typeof autoHeight], containerHeight: $container.style.height }
art.destroy()
return result
})).toEqual({ styles: { width: '100%', height: '75%' }, sizes: [{ width: 640, height: 360 }], heights: [360], returns: ['undefined', 'undefined'], containerHeight: '360px' })
})
test(`${core}: aspect ratio and flip preserve dataset, notices and event order`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const events = []
art.on('aspectRatio', value => events.push(['aspectRatio', value]))
art.on('flip', value => events.push(['flip', value]))
art.aspectRatio = '4:3'
const video = art.template.$video
const ratio = { width: video.style.width, height: video.style.height, margin: video.style.margin, value: art.aspectRatio }
art.flip = 'horizontal'
const flip = art.flip
art.aspectRatio = 'default'
art.flip = 'normal'
const result = { ratio, flip, reset: { width: video.style.width, height: video.style.height, margin: video.style.margin, ratio: art.aspectRatio, flip: art.flip }, events }
art.destroy()
return result
})).toEqual({ ratio: { width: '480px', height: '100%', margin: '0px auto', value: '4:3' }, flip: 'horizontal', reset: { width: '', height: '', margin: '', ratio: 'default', flip: 'normal' }, events: [['aspectRatio', '4:3'], ['flip', 'horizontal'], ['aspectRatio', 'default'], ['flip', 'normal']] })
})
test(`${core}: unresolved media dimensions do not partially change layout or emit NaN height`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const { $video, $player } = art.template
Object.defineProperties($video, { videoWidth: { configurable: true, value: 0 }, videoHeight: { configurable: true, value: 0 } })
$player.style.width = '80%'
$player.style.height = '70%'
const events = []
art.on('autoHeight', value => events.push(Number.isFinite(value)))
art.autoSize()
art.autoHeight()
const result = { width: $player.style.width, height: $player.style.height, finiteHeightEvents: events }
art.destroy()
return result
})).toEqual({ width: core === 'candidate' ? '80%' : '100%', height: '70%', finiteHeightEvents: core === 'candidate' ? [] : [false] })
})
}
@@ -0,0 +1,99 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => {
// Exercise the video-only branch without claiming an iOS device test.
for (const name of ['fullscreenEnabled', 'webkitFullscreenEnabled', 'webkitFullScreenEnabled', 'mozFullScreenEnabled', 'msFullscreenEnabled'])
Object.defineProperty(document, name, { configurable: true, value: false })
const state = new WeakMap()
Object.defineProperties(HTMLVideoElement.prototype, {
webkitSupportsFullscreen: { configurable: true, get: () => true },
webkitDisplayingFullscreen: { configurable: true, get() { return state.get(this) === true } },
webkitPresentationMode: { configurable: true, get() { return state.get(this) ? 'fullscreen' : 'inline' } },
webkitEnterFullscreen: { configurable: true, writable: true, value() {
state.set(this, true)
this.dispatchEvent(new Event('webkitbeginfullscreen'))
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
} },
webkitExitFullscreen: { configurable: true, writable: true, value() {
state.set(this, false)
this.dispatchEvent(new Event('webkitendfullscreen'))
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
} },
})
window.videoFullscreenState = state
})
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.modeEvents = []
window.art.on('fullscreen', value => window.modeEvents.push(value))
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: video-only fullscreen reads its native video state and deduplicates signals`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.fullscreen = true
const entered = art.fullscreen
art.emit('document:webkitfullscreenchange')
art.fullscreen = false
const result = { entered, exited: art.fullscreen, events: [...window.modeEvents] }
art.destroy()
return result
})).toEqual({ entered: core === 'candidate', exited: false, events: core === 'candidate' ? [true, false] : [false] })
})
test(`${core}: destroying video-only fullscreen releases native presentation`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.fullscreen = true
window.modeEvents.length = 0
art.destroy(false)
return { native: art.template.$video.webkitDisplayingFullscreen, events: window.modeEvents }
})).toEqual({ native: core === 'published', events: [] })
})
}
test('candidate: cancelled video-only entry after destroy is released without late player events', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
video.webkitEnterFullscreen = () => {}
art.fullscreen = true
art.destroy(false)
window.videoFullscreenState.set(video, true)
video.dispatchEvent(new Event('webkitbeginfullscreen'))
return { native: video.webkitDisplayingFullscreen, events: window.modeEvents }
})).toEqual({ native: false, events: [] })
})
test('candidate: video-only fullscreen preserves synchronous errors and supports exit retry', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
art.fullscreen = true
const exit = video.webkitExitFullscreen
const failure = new Error('controlled video exit denial')
video.webkitExitFullscreen = () => {
throw failure
}
let rejected = false
try {
art.fullscreen = false
}
catch (error) { rejected = error === failure }
const active = art.fullscreen
video.webkitExitFullscreen = exit
art.fullscreen = false
const result = { rejected, active, exited: art.fullscreen, events: window.modeEvents }
art.destroy()
return result
})).toEqual({ rejected: true, active: true, exited: false, events: [true, false] })
})
+233
View File
@@ -0,0 +1,233 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const removeHtml of [false, true]) {
for (const movedBeforeThrow of [false, true]) {
test(`candidate: failed destroy restoration cannot orphan the player removeHtml=${removeHtml} movedBeforeThrow=${movedBeforeThrow}`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(({ removeHtml, movedBeforeThrow }) => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.fullscreenWeb = true
const original = container.insertBefore
const failure = new Error('destroy restore denied')
container.insertBefore = function (node, anchor) {
if (movedBeforeThrow)
original.call(this, node, anchor)
throw failure
}
let sameError = false
try {
art.destroy(removeHtml)
}
catch (error) {
sameError = error === failure
}
container.insertBefore = original
return { sameError, orphan: player.isConnected && !container.contains(player), retained: container.contains(player), destroyed: art.isDestroy, registered: art.constructor.instances.includes(art) }
}, { removeHtml, movedBeforeThrow })).toEqual({ sameError: true, orphan: false, retained: movedBeforeThrow && !removeHtml, destroyed: true, registered: false })
})
}
}
test('candidate: web fullscreen listener can exit without stale resize or overwritten restoration', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
const player = art.template.$player
player.style.width = '75%'
const style = player.style.cssText
const events = []
art.on('fullscreenWeb', (value) => {
events.push(value)
if (value)
art.fullscreenWeb = false
})
art.on('resize', () => events.push('resize'))
art.fullscreenWeb = true
const result = { events, active: art.fullscreenWeb, restored: player.style.cssText === style }
art.destroy()
return result
})
expect(result).toEqual({ events: [true, false, 'resize'], active: false, restored: true })
})
test('candidate: web fullscreen listener destroy restores ownership without post-destroy resize', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
const style = player.style.cssText
const events = []
art.on('fullscreenWeb', () => art.destroy(false))
art.on('destroy', () => events.push('destroy'))
art.on('resize', () => events.push('resize'))
art.fullscreenWeb = true
art.fullscreenWeb = true
return { events, retained: container.contains(player), style: player.style.cssText === style, fullscreen: player.classList.contains('art-fullscreen-web') }
})).toEqual({ events: ['destroy'], retained: true, style: true, fullscreen: false })
})
test('candidate: a failed body move restores style and keeps its original error', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
const style = player.getAttribute('style')
art.constructor.FULLSCREEN_WEB_IN_BODY = true
const append = document.body.appendChild
const failure = new Error('controlled body insertion failure')
document.body.appendChild = function (node) {
if (node === player)
throw failure
return append.call(this, node)
}
let sameError = false
try {
art.fullscreenWeb = true
}
catch (error) {
sameError = error === failure
}
document.body.appendChild = append
const result = { sameError, retained: container.contains(player), style: player.getAttribute('style') === style, active: art.fullscreenWeb }
art.destroy()
return result
})).toEqual({ sameError: true, retained: true, style: true, active: false })
})
test('candidate: a failed web fullscreen exit can retry restoring the original placement', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
const style = player.getAttribute('style')
art.fullscreenWeb = true
const insert = container.insertBefore
const failure = new Error('controlled restore failure')
container.insertBefore = () => {
throw failure
}
let sameError = false
try {
art.fullscreenWeb = false
}
catch (error) {
sameError = error === failure
}
container.insertBefore = insert
art.fullscreenWeb = false
const result = { sameError, retained: container.contains(player), style: player.getAttribute('style') === style, active: art.fullscreenWeb }
art.destroy()
return result
})).toEqual({ sameError: true, retained: true, style: true, active: false })
})
test('candidate: reentry during web fullscreen restoration preserves the newer active mode', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const { $player: player, $container: container } = art.template
art.constructor.FULLSCREEN_WEB_IN_BODY = true
player.style.width = '65%'
const style = player.getAttribute('style')
art.fullscreenWeb = true
const insert = container.insertBefore
container.insertBefore = function (node, anchor) {
container.insertBefore = insert
const result = insert.call(this, node, anchor)
art.fullscreenWeb = true
return result
}
art.fullscreenWeb = false
const winner = art.fullscreenWeb && player.parentNode === document.body && player.style.width === '100%'
art.fullscreenWeb = false
const restored = container.contains(player) && player.getAttribute('style') === style
art.destroy()
return { winner, restored }
})).toEqual({ winner: true, restored: true })
})
for (const core of ['published', 'candidate']) {
for (const initial of ['', 'width: 70%; height: 80%; color: red;']) {
test(`${core}: web fullscreen restores initial style after repeated entry (${initial || 'empty'})`, async ({ page }, testInfo) => {
await setup(page, core)
const result = await page.evaluate((initial) => {
const art = window.art
art.constructor.FULLSCREEN_WEB_IN_BODY = false
const player = art.template.$player
player.style.cssText = initial
const before = player.style.cssText
const events = []
let restoredAtExit
art.on('fullscreenWeb', (value) => {
events.push(value)
if (!value)
restoredAtExit = player.style.cssText
})
art.fullscreenWeb = true
art.fullscreenWeb = true
art.fullscreenWeb = false
const result = { restored: restoredAtExit === before, events, active: art.fullscreenWeb, dimensions: [player.style.width, player.style.height, player.style.color], styles: { before, restoredAtExit, after: player.style.cssText } }
art.destroy()
return result
}, initial)
const { styles, ...observed } = result
await testInfo.attach('fullscreen-styles', { contentType: 'application/json', body: JSON.stringify(styles) })
expect(observed).toEqual({ restored: core === 'candidate', events: [true, true, false], active: false, dimensions: core === 'candidate' ? (initial ? ['70%', '80%', 'red'] : ['', '', '']) : ['100%', '100%', initial ? 'red' : ''] })
})
}
for (const changeFlag of [false, true]) {
test(`${core}: body fullscreen restores original parent and sibling when flag changes=${changeFlag}`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate((changeFlag) => {
const art = window.art
const player = art.template.$player
const wrapper = document.createElement('div')
const sibling = document.createElement('span')
art.template.$container.append(wrapper)
wrapper.append(player, sibling)
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.fullscreenWeb = true
const entered = player.parentNode === document.body
if (changeFlag)
art.constructor.FULLSCREEN_WEB_IN_BODY = false
art.fullscreenWeb = false
const result = { entered, parent: player.parentNode === wrapper, sibling: player.nextSibling === sibling, inBody: player.parentNode === document.body }
art.destroy()
player.remove()
return result
}, changeFlag)
expect(result).toEqual({ entered: true, parent: core === 'candidate', sibling: core === 'candidate', inBody: core === 'published' && changeFlag })
})
}
for (const removeHtml of [false, true]) {
test(`${core}: destroy while web fullscreen removeHtml=${removeHtml} restores DOM ownership`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate((removeHtml) => {
const art = window.art
const { $player: player, $container: container } = art.template
const style = player.style.cssText
art.constructor.FULLSCREEN_WEB_IN_BODY = true
art.fullscreenWeb = true
art.destroy(removeHtml)
const result = { orphan: player.parentNode === document.body, retained: container.contains(player), style: player.style.cssText === style, fullscreen: player.classList.contains('art-fullscreen-web') }
player.remove()
return result
}, removeHtml)
expect(result).toEqual(core === 'candidate'
? { orphan: false, retained: !removeHtml, style: true, fullscreen: false }
: { orphan: true, retained: false, style: false, fullscreen: true })
})
}
}
+30
View File
@@ -0,0 +1,30 @@
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 { miniPosition, clampMini } = await loadModules({
miniPosition: { file: 'packages/artplayer/src/display/mini-layout', name: 'miniPosition' },
clampMini: { file: 'packages/artplayer/src/display/mini-layout', name: 'clampMini' },
})
const geometry = { width: 320, height: 180, viewportWidth: 960, viewportHeight: 720 }
test('mini preserves finite visible stored coordinates including zero', () => {
assert.deepEqual(miniPosition(0, 0, geometry), { left: 0, top: 0, reset: false })
assert.deepEqual(miniPosition(640, 540, geometry), { left: 640, top: 540, reset: false })
})
test('mini replaces missing, non-finite and offscreen storage with the legacy 50px inset', () => {
for (const [left, top] of [[undefined, null], [Number.NaN, 20], [20, Infinity], ['20', 20], [-1, 50], [800, 400], [200, 700]])
assert.deepEqual(miniPosition(left, top, geometry), { left: 590, top: 490, reset: true })
})
test('mini default position remains reachable on small viewports', () => {
assert.deepEqual(miniPosition(null, null, { width: 280, height: 160, viewportWidth: 280, viewportHeight: 160 }), { left: 0, top: 0, reset: true })
})
test('mini drag clamps both boundaries without changing positions inside the viewport', () => {
assert.deepEqual(clampMini(-100, 900, geometry), { left: 0, top: 540 })
assert.deepEqual(clampMini(600, 400, geometry), { left: 600, top: 400 })
assert.deepEqual(clampMini(900, -2, geometry), { left: 640, top: 0 })
})
+324
View File
@@ -0,0 +1,324 @@
import assert from 'node:assert/strict'
import { getEventListeners } from 'node:events'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { loadModules } from './helpers/load.js'
const { ResourceScope, requestFullscreen, nativeFullscreen, beginLifecycle, getScope } = await loadModules({
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
requestFullscreen: { file: 'packages/artplayer/src/display/fullscreen-request', name: 'requestFullscreen' },
nativeFullscreen: { file: 'packages/artplayer/src/display/native-fullscreen', name: 'nativeFullscreen' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture() {
const classes = new Set()
const target = { classList: {
remove: name => classes.delete(name),
toggle: (name, value) => value ? classes.add(name) : classes.delete(name),
} }
const adapter = {
document: new EventTarget(),
target,
element: null,
elementProperty: 'fullscreenElement',
changeEvent: 'fullscreenchange',
errorEvent: 'fullscreenerror',
request: () => Promise.resolve(),
exit: () => Promise.resolve(),
}
Object.defineProperty(adapter.document, 'fullscreenElement', { get: () => adapter.element })
const events = []
const art = {
template: { $player: target, $video: {} },
notice: { show: '' },
state: 'standard',
emit: (...args) => events.push(args),
}
beginLifecycle(art)
const descriptor = nativeFullscreen(art, adapter)
const change = (element) => {
adapter.element = element
adapter.document.dispatchEvent(new Event(adapter.changeEvent))
}
const listeners = () => [adapter.changeEvent, adapter.errorEvent].map(name => getEventListeners(adapter.document, name).length)
return { art, adapter, classes, descriptor, change, events, listeners }
}
test('fullscreen native invocation retains the caller stack and original rejection', async () => {
const { adapter } = fixture()
const parent = new ResourceScope()
const failure = new Error('denied')
let called = false
adapter.request = () => {
called = true
return Promise.reject(failure)
}
const operation = requestFullscreen(adapter, parent, true, () => assert.fail('not cancelled'))
assert.equal(called, true)
await assert.rejects(operation.promise, error => error === failure)
assert.equal(getEventListeners(adapter.document, adapter.changeEvent).length, 1)
assert.equal(getEventListeners(adapter.document, adapter.errorEvent).length, 1)
parent.dispose()
})
test('void fullscreen APIs wait for the actual change and remove operation listeners', async () => {
const { adapter, change, listeners } = fixture()
adapter.request = () => undefined
const parent = new ResourceScope()
const operation = requestFullscreen(adapter, parent, true, () => {})
let settled = false
void operation.promise.then(() => settled = true)
await setImmediate()
assert.equal(settled, false)
assert.deepEqual(listeners(), [2, 2])
change(adapter.target)
await operation.promise
assert.deepEqual(listeners(), [1, 1])
parent.dispose()
})
test('void native exit waits while the instance video still owns fullscreen', async () => {
const { art, adapter, descriptor, change, listeners } = fixture()
change(art.template.$video)
adapter.exit = () => undefined
let settled = false
const exit = descriptor.set(false).then(() => settled = true)
await setImmediate()
assert.equal(settled, false)
assert.deepEqual(listeners(), [2, 2])
change(null)
await exit
assert.equal(settled, true)
assert.deepEqual(listeners(), [1, 1])
getScope(art).dispose()
})
test('fullscreen request disposal settles without waiting and handles late completion once', async () => {
const { adapter, listeners } = fixture()
let complete
let late = 0
adapter.request = () => new Promise(resolve => complete = resolve)
const parent = new ResourceScope()
const operation = requestFullscreen(adapter, parent, true, () => late++)
parent.dispose()
await operation.promise
assert.deepEqual(listeners(), [1, 1])
complete()
await setImmediate()
assert.equal(late, 1)
})
test('closed fullscreen request scopes never call the browser', async () => {
const { adapter } = fixture()
adapter.request = () => assert.fail('closed request invoked')
const parent = new ResourceScope()
parent.dispose()
await requestFullscreen(adapter, parent, true, () => {}).promise
})
test('native promise rejection retains its error when fullscreenerror arrives first', async () => {
const { adapter } = fixture()
const failure = new Error('browser permission denied')
adapter.request = () => {
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
return Promise.reject(failure)
}
const parent = new ResourceScope()
await assert.rejects(requestFullscreen(adapter, parent, true, () => {}).promise, error => error === failure)
parent.dispose()
})
test('void fullscreen errors reject and release both temporary listeners', async () => {
const { adapter, listeners } = fixture()
adapter.request = () => undefined
const parent = new ResourceScope()
const operation = requestFullscreen(adapter, parent, true, () => {})
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
await assert.rejects(operation.promise, /Fullscreen request failed/)
assert.deepEqual(listeners(), [1, 1])
parent.dispose()
})
test('synchronous void fullscreen errors reject without waiting for a second event', async () => {
const { adapter, listeners } = fixture()
adapter.request = () => {
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
}
const parent = new ResourceScope()
await assert.rejects(requestFullscreen(adapter, parent, true, () => {}).promise, /Fullscreen request failed/)
assert.deepEqual(listeners(), [1, 1])
parent.dispose()
})
for (const close of ['exit', 'destroy']) {
test(`pending fullscreen ${close} settles promptly and exits its late native entry`, async () => {
const { art, adapter, descriptor, listeners } = fixture()
let complete
let exits = 0
adapter.request = () => new Promise(resolve => complete = resolve)
adapter.exit = () => {
exits++
adapter.element = null
return Promise.resolve()
}
const entering = descriptor.set(true)
if (close === 'destroy')
getScope(art).dispose()
else
await descriptor.set(false)
await entering
assert.deepEqual(listeners(), close === 'destroy' ? [0, 0] : [1, 1])
adapter.element = adapter.target
complete()
await setImmediate()
assert.equal(exits, 1)
assert.equal(descriptor.get(), false)
})
}
test('late cancelled fullscreen completion cannot exit another player', async () => {
const { art, adapter, descriptor } = fixture()
let complete
adapter.request = () => new Promise(resolve => complete = resolve)
adapter.exit = () => assert.fail('exited another player')
const entering = descriptor.set(true)
getScope(art).dispose()
await entering
adapter.element = {}
complete()
await setImmediate()
})
test('cancelled void fullscreen requests retain no instance listeners and release late entry', async () => {
const { art, adapter, descriptor, change, listeners, events } = fixture()
let exits = 0
adapter.request = () => undefined
adapter.exit = () => {
exits++
adapter.element = null
}
const entering = descriptor.set(true)
getScope(art).dispose()
await entering
// One shared document guard remains; both per-instance listeners are gone.
assert.deepEqual(listeners(), [1, 0])
change({})
assert.equal(exits, 0)
change(adapter.target)
assert.equal(exits, 1)
assert.equal(adapter.element, null)
assert.deepEqual(events, [])
})
test('new native entry supersedes an abandoned void request for the same target', async () => {
const { art, adapter, descriptor, change } = fixture()
adapter.request = () => undefined
adapter.exit = () => assert.fail('new entry was mistaken for cancellation')
const first = descriptor.set(true)
await descriptor.set(false)
await first
const second = descriptor.set(true)
change(adapter.target)
await second
assert.equal(descriptor.get(), true)
adapter.element = null
getScope(art).dispose()
})
test('cancelled void entry never emits a stale true event or sets its fullscreen class', async () => {
const { art, adapter, descriptor, change, events, classes } = fixture()
adapter.request = () => undefined
adapter.exit = () => change(null)
const entering = descriptor.set(true)
await descriptor.set(false)
await entering
change(adapter.target)
assert.deepEqual(events, [])
assert.equal(classes.has('art-fullscreen'), false)
assert.equal(descriptor.get(), false)
getScope(art).dispose()
})
test('cancelled promise entry rejects a late change before its native promise resolves', async () => {
const { art, adapter, descriptor, change, events, classes } = fixture()
let complete
adapter.request = () => new Promise(resolve => complete = resolve)
adapter.exit = () => {
change(null)
return Promise.resolve()
}
const entering = descriptor.set(true)
await descriptor.set(false)
await entering
change(adapter.target)
complete()
await setImmediate()
assert.deepEqual(events, [])
assert.equal(classes.has('art-fullscreen'), false)
assert.equal(descriptor.get(), false)
getScope(art).dispose()
})
test('fullscreen rejection stays observable through the descriptor and reports notice', async () => {
const { art, adapter, descriptor, listeners } = fixture()
const failure = new Error('denied')
adapter.request = () => Promise.reject(failure)
await assert.rejects(descriptor.set(true), error => error === failure)
assert.equal(art.notice.show, failure)
assert.deepEqual(listeners(), [1, 1])
getScope(art).dispose()
})
test('fullscreen event precedes the class update and resize', async () => {
const { art, adapter, descriptor, classes, change } = fixture()
const observations = []
art.emit = (name, value) => observations.push([name, value, classes.has('art-fullscreen')])
adapter.request = () => {
change(adapter.target)
return Promise.resolve()
}
await descriptor.set(true)
assert.deepEqual(observations, [['fullscreen', true, false], ['resize', undefined, true]])
getScope(art).dispose()
})
test('reentrant true during fullscreen entry still updates its class and resize', async () => {
const { art, adapter, descriptor, classes, change, events } = fixture()
art.emit = (...args) => {
events.push(args)
if (args[0] === 'fullscreen' && args[1])
void descriptor.set(true)
}
adapter.request = () => {
change(adapter.target)
return Promise.resolve()
}
await descriptor.set(true)
assert.equal(classes.has('art-fullscreen'), true)
assert.deepEqual(events, [['fullscreen', true], ['resize']])
getScope(art).dispose()
})
test('reentrant exit during fullscreen entry prevents stale class and resize', async () => {
const { art, adapter, descriptor, classes, change, events } = fixture()
art.emit = (...args) => {
events.push(args)
if (args[0] === 'fullscreen' && args[1])
void descriptor.set(false)
}
adapter.request = () => {
change(adapter.target)
return Promise.resolve()
}
adapter.exit = () => {
change(null)
return Promise.resolve()
}
await descriptor.set(true)
assert.equal(classes.has('art-fullscreen'), false)
assert.deepEqual(events, [['fullscreen', true], ['fullscreen', false], ['resize']])
getScope(art).dispose()
})
+141
View File
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { loadModules } from './helpers/load.js'
const { nativeOrientation, beginLifecycle, getScope } = await loadModules({
nativeOrientation: { file: 'packages/artplayer/src/display/orientation-native', name: 'nativeOrientation' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(shared, need = () => true) {
const calls = []
const locks = []
const orientation = shared || {
type: 'portrait-primary',
lock(mode) {
calls.push(mode)
return new Promise((resolve, reject) => locks.push({ resolve, reject }))
},
unlock() {
calls.push('unlock')
},
}
const classes = new Set()
const art = {
template: { $player: { classList: { add: name => classes.add(name), remove: name => classes.delete(name) }, ownerDocument: { defaultView: { screen: { orientation } } } } },
notice: { show: '' },
}
beginLifecycle(art)
return { art, orientation, calls, locks, classes, set: nativeOrientation(art, need), dispose: () => getScope(art).dispose() }
}
test('orientation invokes synchronously, marks after success, and unlocks on exit', async () => {
const f = fixture()
assert.equal(f.set(true), undefined)
assert.deepEqual(f.calls, ['landscape'])
assert.equal(f.classes.size, 0)
f.locks[0].resolve()
await setImmediate()
assert.deepEqual([...f.classes], ['art-auto-orientation-fullscreen'])
f.set(false)
assert.deepEqual(f.calls, ['landscape', 'unlock'])
assert.equal(f.classes.size, 0)
f.dispose()
})
test('orientation repeated entry uses one pending or active lock', async () => {
const f = fixture()
f.set(true)
f.set(true)
f.locks[0].resolve()
await setImmediate()
f.set(true)
assert.deepEqual(f.calls, ['landscape'])
f.dispose()
})
test('cancelled late orientation success is released without reactivating classes', async () => {
const f = fixture()
f.set(true)
f.set(false)
f.locks[0].resolve()
await setImmediate()
assert.deepEqual(f.calls, ['landscape', 'unlock', 'unlock'])
assert.equal(f.classes.size, 0)
assert.equal(f.art.notice.show, '')
f.dispose()
})
test('destroyed pending orientation ignores late rejection and further events', async () => {
const f = fixture()
f.set(true)
f.dispose()
f.art.notice.show = 'replacement notice'
f.locks[0].reject(new Error('aborted'))
await setImmediate()
f.set(true)
assert.deepEqual(f.calls, ['landscape', 'unlock'])
assert.equal(f.art.notice.show, 'replacement notice')
assert.equal(f.classes.size, 0)
})
test('orientation failure preserves original error identity and allows retry', async () => {
const f = fixture()
const error = new Error('unsupported')
f.set(true)
f.locks[0].reject(error)
await setImmediate()
assert.equal(f.art.notice.show, error)
f.orientation.type = 'landscape-primary'
f.set(true)
assert.deepEqual(f.calls, ['landscape', 'portrait'])
f.locks[1].resolve()
await setImmediate()
f.dispose()
})
test('orientation synchronous lock failures are reported and cleanup failure remains best effort', () => {
const f = fixture()
const error = new Error('sync denied')
f.orientation.lock = () => {
throw error
}
f.set(true)
assert.equal(f.art.notice.show, error)
f.orientation.lock = () => new Promise(() => {})
f.orientation.unlock = () => {
throw new Error('unlock denied')
}
f.set(true)
assert.doesNotThrow(f.dispose)
})
test('superseded orientation completion and old destruction cannot unlock a newer instance', async () => {
const first = fixture()
const second = fixture(first.orientation)
first.set(true)
second.set(true)
first.locks[1].resolve()
await setImmediate()
first.locks[0].resolve()
await setImmediate()
first.dispose()
assert.deepEqual(first.calls, ['landscape', 'landscape'])
assert.equal(first.classes.size, 0)
assert.equal(second.classes.size, 1)
second.dispose()
assert.deepEqual(first.calls, ['landscape', 'landscape', 'unlock'])
})
test('no rotation requirement and incomplete platform capabilities do not call lock', () => {
const f = fixture(undefined, () => false)
f.set(true)
assert.deepEqual(f.calls, [])
f.dispose()
const missing = fixture({ type: 'portrait-primary', lock: true })
assert.doesNotThrow(() => missing.set(true))
missing.dispose()
})
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import { getEventListeners } from 'node:events'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { loadModules } from './helpers/load.js'
const { Emitter, nativePip, webkitPip, beginLifecycle, getScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
nativePip: { file: 'packages/artplayer/src/display/native-pip', name: 'nativePip' },
webkitPip: { file: 'packages/artplayer/src/display/webkit-pip', name: 'webkitPip' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(kind = 'native') {
const video = new EventTarget()
video.ownerDocument = {
pictureInPictureElement: null,
exitPictureInPicture() {
const previous = this.pictureInPictureElement
this.pictureInPictureElement = null
previous?.dispatchEvent(new Event('leavepictureinpicture'))
return Promise.resolve()
},
}
video.requestPictureInPicture = () => {
video.ownerDocument.pictureInPictureElement = video
video.dispatchEvent(new Event('enterpictureinpicture'))
return Promise.resolve({ width: 320, height: 180 })
}
video.webkitPresentationMode = 'inline'
video.webkitSetPresentationMode = (mode) => {
video.webkitPresentationMode = mode
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
video.dispatchEvent(new Event(mode === 'picture-in-picture' ? 'enterpictureinpicture' : 'leavepictureinpicture'))
}
const art = Object.assign(new Emitter(), { template: { $video: video }, state: 'standard', notice: { show: '' }, i18n: { get: key => key } })
beginLifecycle(art)
Object.defineProperty(art, 'pip', kind === 'native' ? nativePip(art) : webkitPip(art))
const events = []
art.on('pip', value => events.push(value))
return { art, video, document: video.ownerDocument, events }
}
test('native PiP invokes immediately, retains element identity and void setter', () => {
const { art, video, events } = fixture()
assert.equal(Object.getOwnPropertyDescriptor(art, 'pip').set(true), undefined)
assert.equal(art.pip, video)
assert.deepEqual(events, [true])
art.pip = false
assert.equal(art.pip, null)
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('native PiP ignores a queued leave from an older session while the video still owns PiP', () => {
const { art, video, events } = fixture()
art.pip = true
video.dispatchEvent(new Event('leavepictureinpicture'))
assert.equal(art.pip, video)
assert.deepEqual(events, [true])
art.pip = false
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('native PiP rejection preserves the notice error without unhandled rejection', async () => {
const { art, video, events } = fixture()
const failure = new Error('denied')
video.requestPictureInPicture = () => Promise.reject(failure)
art.pip = true
await setImmediate()
assert.equal(art.notice.show, failure)
assert.equal(art.pip, null)
assert.deepEqual(events, [])
getScope(art).dispose()
})
test('native PiP synchronous errors remain synchronous and retain identity', () => {
const { art, video } = fixture()
const failure = new Error('sync denial')
video.requestPictureInPicture = () => {
throw failure
}
assert.throws(() => {
art.pip = true
}, error => error === failure)
getScope(art).dispose()
})
test('obsolete native PiP failure does not replace the latest notice', async () => {
const { art, video } = fixture()
let reject
video.requestPictureInPicture = () => new Promise((resolve, fail) => reject = fail)
art.pip = true
art.pip = false
art.notice.show = 'current notice'
reject(new Error('old request'))
await setImmediate()
assert.equal(art.notice.show, 'current notice')
getScope(art).dispose()
})
test('destroyed native PiP ignores late completion when another video owns PiP', async () => {
const { art, video, document } = fixture()
let complete
video.requestPictureInPicture = () => new Promise(resolve => complete = resolve)
art.pip = true
getScope(art).dispose()
document.pictureInPictureElement = {}
document.exitPictureInPicture = () => assert.fail('exited another video')
complete({})
await setImmediate()
assert.equal(getEventListeners(video, 'enterpictureinpicture').length, 0)
assert.equal(getEventListeners(video, 'leavepictureinpicture').length, 0)
})
test('native PiP exit rejection keeps real state and permits retry', async () => {
const { art, document, video, events } = fixture()
art.pip = true
await setImmediate()
const exit = document.exitPictureInPicture
const failure = new Error('exit denied')
document.exitPictureInPicture = () => Promise.reject(failure)
art.pip = false
await setImmediate()
assert.equal(art.pip, video)
assert.equal(art.notice.show, failure)
document.exitPictureInPicture = exit
art.pip = false
await setImmediate()
assert.equal(art.pip, null)
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('WebKit PiP retains boolean getter and repeated synchronous setter events', () => {
const { art, events } = fixture('webkit')
art.pip = true
art.pip = true
assert.equal(art.pip, true)
art.pip = false
assert.equal(art.pip, false)
assert.deepEqual(events, [true, true, false])
getScope(art).dispose()
})
test('WebKit PiP observes external presentation transitions without duplicate native signals', () => {
const { art, video, events } = fixture('webkit')
video.webkitSetPresentationMode('picture-in-picture')
video.webkitSetPresentationMode('inline')
assert.deepEqual(events, [true, false])
getScope(art).dispose()
})
test('WebKit PiP false does not exit a different fullscreen presentation', () => {
const { art, video } = fixture('webkit')
video.webkitPresentationMode = 'fullscreen'
art.pip = false
assert.equal(video.webkitPresentationMode, 'fullscreen')
getScope(art).dispose()
})
test('destroyed WebKit PiP releases delayed entry without retaining instance listeners', () => {
const { art, video, events } = fixture('webkit')
const setMode = video.webkitSetPresentationMode
video.webkitSetPresentationMode = () => {}
art.pip = true
getScope(art).dispose()
video.webkitSetPresentationMode = setMode
setMode('picture-in-picture')
assert.equal(video.webkitPresentationMode, 'inline')
assert.deepEqual(events, [])
assert.equal(getEventListeners(video, 'webkitpresentationmodechanged').length, 0)
})
test('WebKit PiP destruction inside entry callback stops stale notifications', () => {
const { art, video, events } = fixture('webkit')
art.on('pip', () => getScope(art).dispose())
art.pip = true
assert.equal(video.webkitPresentationMode, 'inline')
assert.deepEqual(events, [true])
})
for (const kind of ['native', 'webkit']) {
test(`${kind} PiP reentrant cancellation retains its branch-specific setter event contract`, async () => {
const { art, video, events } = fixture(kind)
// eslint-disable-next-line accessor-pairs -- Intercept mode synchronization without implementing the unrelated state getter.
Object.defineProperty(art, 'state', { set(value) {
if (value === 'pip')
art.pip = false
} })
if (kind === 'native')
await video.requestPictureInPicture()
else
video.webkitSetPresentationMode('picture-in-picture')
assert.deepEqual(events, kind === 'native' ? [] : [false])
getScope(art).dispose()
})
}
test('WebKit PiP checks current support and can retry after media becomes eligible', () => {
const { art, video, events } = fixture('webkit')
video.webkitSupportsPresentationMode = () => false
art.pip = true
assert.equal(art.pip, false)
assert.equal(art.notice.show, 'PIP Not Supported')
assert.deepEqual(events, [])
video.webkitSupportsPresentationMode = () => true
art.pip = true
assert.equal(art.pip, true)
assert.deepEqual(events, [true])
getScope(art).dispose()
})
+43
View File
@@ -0,0 +1,43 @@
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 { containSize, proportionalHeight } = await loadModules({
containSize: { file: 'packages/artplayer/src/display/sizing', name: 'containSize' },
proportionalHeight: { file: 'packages/artplayer/src/display/sizing', name: 'proportionalHeight' },
})
test('contained media preserves its ratio, fits both bounds and fills one axis', () => {
for (const container of [{ width: 640, height: 480 }, { width: 160, height: 900 }, { width: 0.5, height: 0.25 }]) {
for (const ratio of [16 / 9, 9 / 16, 1, 4 / 3, 0.001, 1000]) {
const size = containSize(container, ratio)
assert.ok(size.width <= container.width && size.height <= container.height)
assert.ok(Math.abs(size.width / size.height - ratio) <= ratio * 1e-12)
assert.ok(size.width === container.width || size.height === container.height)
}
}
})
test('zero, negative and non-finite geometry cannot produce layout writes', () => {
for (const invalid of [0, -1, Number.NaN, Infinity, -Infinity]) {
assert.equal(containSize({ width: invalid, height: 480 }, 16 / 9), undefined)
assert.equal(containSize({ width: 640, height: invalid }, 16 / 9), undefined)
assert.equal(containSize({ width: 640, height: 480 }, invalid), undefined)
assert.equal(proportionalHeight(invalid, { width: 1920, height: 1080 }), undefined)
assert.equal(proportionalHeight(640, { width: invalid, height: 1080 }), undefined)
assert.equal(proportionalHeight(640, { width: 1920, height: invalid }), undefined)
}
})
test('auto height uses media proportions and recovers when dimensions become ready', () => {
assert.equal(proportionalHeight(640, { width: 0, height: 0 }), undefined)
assert.equal(proportionalHeight(640, { width: 1920, height: 1080 }), 360)
assert.equal(proportionalHeight(360, { width: 1080, height: 1920 }), 640)
})
test('overflow and underflow never escape as infinite or zero layout measurements', () => {
assert.equal(proportionalHeight(Number.MAX_VALUE, { width: Number.MIN_VALUE, height: 1 }), undefined)
assert.equal(proportionalHeight(Number.MIN_VALUE, { width: Number.MAX_VALUE, height: 1 }), undefined)
assert.equal(containSize({ width: Number.MIN_VALUE, height: Number.MIN_VALUE }, Number.MAX_VALUE), undefined)
})
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict'
import { getEventListeners } from 'node:events'
// 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, videoFullscreen, beginLifecycle, getScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
videoFullscreen: { file: 'packages/artplayer/src/display/video-fullscreen', name: 'videoFullscreen' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture() {
const video = new EventTarget()
video.ownerDocument = { fullscreenElement: null }
video.webkitDisplayingFullscreen = false
video.webkitEnterFullscreen = function () {
this.webkitDisplayingFullscreen = true
this.dispatchEvent(new Event('webkitbeginfullscreen'))
}
video.webkitExitFullscreen = function () {
this.webkitDisplayingFullscreen = false
this.dispatchEvent(new Event('webkitendfullscreen'))
}
const art = Object.assign(new Emitter(), { template: { $video: video }, state: 'standard' })
beginLifecycle(art)
Object.defineProperty(art, 'fullscreen', videoFullscreen(art))
const events = []
art.on('fullscreen', value => events.push(['fullscreen', value]))
art.on('resize', () => events.push(['resize']))
return { art, video, events }
}
test('video-only native callbacks preserve boolean state and normal event/resize order', () => {
const { art, video, events } = fixture()
art.fullscreen = true
art.emit('document:webkitfullscreenchange')
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
assert.equal(art.fullscreen, true)
art.fullscreen = false
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [['fullscreen', true], ['resize'], ['fullscreen', false], ['resize']])
getScope(art).dispose()
assert.equal(getEventListeners(video, 'webkitbeginfullscreen').length, 0)
assert.equal(getEventListeners(video, 'webkitendfullscreen').length, 0)
assert.equal(getEventListeners(video, 'webkitpresentationmodechanged').length, 0)
})
test('video presentation PiP is distinct from fullscreen even if displayingFullscreen is true', () => {
const { art, video, events } = fixture()
video.webkitPresentationMode = 'picture-in-picture'
video.webkitDisplayingFullscreen = true
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [])
getScope(art).dispose()
})
test('video fullscreen falls back to native events if a shim has no state property', () => {
const { art, video } = fixture()
delete video.webkitDisplayingFullscreen
video.dispatchEvent(new Event('webkitbeginfullscreen'))
assert.equal(art.fullscreen, true)
video.dispatchEvent(new Event('webkitendfullscreen'))
assert.equal(art.fullscreen, false)
getScope(art).dispose()
})
test('video fullscreen destruction during its entry event stops stale resize and exits', () => {
const { art, video, events } = fixture()
art.on('fullscreen', (value) => {
if (value)
getScope(art).dispose()
})
art.fullscreen = true
assert.equal(video.webkitDisplayingFullscreen, false)
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [['fullscreen', true]])
})
test('video fullscreen same-state reentry preserves the one resize', () => {
const { art, events } = fixture()
art.on('fullscreen', (value) => {
if (value)
art.fullscreen = true
})
art.fullscreen = true
assert.equal(art.fullscreen, true)
assert.deepEqual(events, [['fullscreen', true], ['resize']])
getScope(art).dispose()
})
test('video fullscreen synchronous request errors keep their identity and allow retry', () => {
const { art, video } = fixture()
const enter = video.webkitEnterFullscreen
const error = new Error('denied')
video.webkitEnterFullscreen = () => {
throw error
}
assert.throws(() => {
art.fullscreen = true
}, actual => actual === error)
video.webkitEnterFullscreen = enter
art.fullscreen = true
assert.equal(art.fullscreen, true)
getScope(art).dispose()
})
test('cancelled video fullscreen entry is released without stale player events', () => {
const { art, video, events } = fixture()
const enter = video.webkitEnterFullscreen
video.webkitEnterFullscreen = () => {}
art.fullscreen = true
art.fullscreen = false
enter.call(video)
assert.equal(video.webkitDisplayingFullscreen, false)
assert.equal(art.fullscreen, false)
assert.deepEqual(events, [])
getScope(art).dispose()
})
test('new video fullscreen entry supersedes the previous cancellation', () => {
const { art, video, events } = fixture()
const enter = video.webkitEnterFullscreen
video.webkitEnterFullscreen = () => {}
art.fullscreen = true
art.fullscreen = false
video.webkitEnterFullscreen = enter
art.fullscreen = true
assert.equal(art.fullscreen, true)
assert.deepEqual(events, [['fullscreen', true], ['resize']])
getScope(art).dispose()
})
test('late entry on a video shim without state properties is released after destroy', () => {
const { art, video, events } = fixture()
delete video.webkitDisplayingFullscreen
let exits = 0
video.webkitEnterFullscreen = () => {}
video.webkitExitFullscreen = () => {
exits++
video.dispatchEvent(new Event('webkitendfullscreen'))
}
art.fullscreen = true
getScope(art).dispose()
video.dispatchEvent(new Event('webkitbeginfullscreen'))
assert.equal(exits, 1)
assert.deepEqual(events, [])
assert.equal(getEventListeners(video, 'webkitbeginfullscreen').length, 0)
})
+13
View File
@@ -0,0 +1,13 @@
import type { MiniHost } from '../../packages/artplayer/src/display/types'
import miniMix from '../../packages/artplayer/src/player/miniMix'
declare const host: MiniHost
miniMix(host)
const active: boolean = host.mini
host.mini = true
host.mini = false
// @ts-expect-error Mini retains boolean setters.
host.mini = 'true'
// @ts-expect-error Mini storage persists numeric coordinates.
host.storage.set('left', '25px')
export { active }
+14
View File
@@ -0,0 +1,14 @@
import type { OrientationHost, OrientationLock } from '../../packages/artplayer/src/display/orientation-types'
import autoOrientation from '../../packages/artplayer/src/plugins/autoOrientation'
declare const host: OrientationHost
declare const lock: OrientationLock
const plugin = autoOrientation(host)
const state: boolean = plugin.state
// @ts-expect-error The builtin exposes a getter, not a writable flag.
plugin.state = true
// @ts-expect-error Internal orientation requests use the opposite portrait/landscape mode.
lock.lock('upside-down')
// @ts-expect-error Fullscreen events carry boolean state.
host.on('fullscreen', (value: string) => value)
export { state }
+13
View File
@@ -0,0 +1,13 @@
import type { PipHost } from '../../packages/artplayer/src/display/types'
import pipMix from '../../packages/artplayer/src/player/pipMix'
declare const host: PipHost
pipMix(host)
const state: HTMLElement | null | boolean = host.pip
// @ts-expect-error Native PiP historically returns the element or null, not just boolean.
const booleanOnly: boolean = host.pip
host.pip = true
host.pip = false
// @ts-expect-error PiP setters accept a boolean input.
host.pip = 'picture-in-picture'
export { booleanOnly, state }
+21
View File
@@ -0,0 +1,21 @@
import type { AutoHeightHost, AutoSizeHost } from '../../packages/artplayer/src/display/sizing-types'
import { containSize, proportionalHeight } from '../../packages/artplayer/src/display/sizing'
import autoHeightMix from '../../packages/artplayer/src/player/autoHeightMix'
import autoSizeMix from '../../packages/artplayer/src/player/autoSizeMix'
declare const sizeHost: AutoSizeHost
declare const heightHost: AutoHeightHost
autoSizeMix(sizeHost)
autoHeightMix(heightHost)
const height: number | undefined = proportionalHeight(640, { width: 1920, height: 1080 })
const size = containSize({ width: 640, height: 480 }, 16 / 9)
if (size) {
const axis: 'width' | 'height' = size.axis
sizeHost.emit('autoSize', { width: size.width, height: size.height })
void axis
}
// @ts-expect-error The internal measurement cannot be a CSS string.
heightHost.emit('autoHeight', '360px')
// @ts-expect-error Dimensions must describe both axes.
proportionalHeight(640, { width: 1920 })
export { height }
+15
View File
@@ -0,0 +1,15 @@
import type { WebFullscreenHost } from '../../packages/artplayer/src/display/types'
import { capturePlacement, restorePlacement } from '../../packages/artplayer/src/display/placement'
import fullscreenWebMix from '../../packages/artplayer/src/player/fullscreenWebMix'
declare const host: WebFullscreenHost
fullscreenWebMix(host)
host.fullscreenWeb = true
const active: boolean = host.fullscreenWeb
const placement = capturePlacement(host.template.$player)
restorePlacement(placement)
// @ts-expect-error Display setters retain a boolean input.
host.fullscreenWeb = 'true'
// @ts-expect-error DOM placement cannot be constructed from a string.
capturePlacement('player')
export { active }