mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-28 10:31:20 -08:00
refactor(core): CORE-13 type component registries and own control lifetimes
This commit is contained in:
1 parent
9eb7c945ca
commit
697817eceb
68 files changed
+10100
-1925
No files matched your search
@@ -67,4 +67,4 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* 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 a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
|
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var 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
|
||||||
@@ -67,4 +67,4 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* 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,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var 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 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}()),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,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var 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 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}()),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
|
||||||
+743
-536
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -49,7 +49,7 @@
|
|||||||
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
"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",
|
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
|
||||||
"typecheck": "node scripts/typecheck.mjs",
|
"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: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": "yarn test:node && yarn test:baseline",
|
"test": "yarn test:node && yarn test:baseline",
|
||||||
"test:browser": "playwright test",
|
"test:browser": "playwright test",
|
||||||
"test:browser:install": "playwright install chromium firefox webkit",
|
"test:browser:install": "playwright install chromium firefox webkit",
|
||||||
|
|||||||
@@ -4,6 +4,74 @@ 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
|
The production entry is still `src/index.js`; this document marks actual migrated
|
||||||
boundaries rather than describing the entire core as TypeScript.
|
boundaries rather than describing the entire core as TypeScript.
|
||||||
|
|
||||||
|
## Components and controls (CORE-13)
|
||||||
|
|
||||||
|
The public registries remain Component-based. utils/component.ts owns add/update/remove,
|
||||||
|
cache and dynamic name aliases; component/dom.ts owns insertion and known template
|
||||||
|
queries; component/types.ts defines the minimal generic host and callback shapes.
|
||||||
|
component/resources.ts owns each entry's child ResourceScope, DOM proxies and guarded
|
||||||
|
Emitter subscriptions. control/resources.ts specializes the shared subscriptions with
|
||||||
|
UIEvents; it does not create a second event bus. Builtins depend on these capabilities,
|
||||||
|
not the full player class. CORE-20 still has to integrate the constructor facade.
|
||||||
|
|
||||||
|
Control index.ts preserves routing and visibility behavior. builtins.ts preserves
|
||||||
|
installation order and option/platform conditions. selector.ts owns item binding,
|
||||||
|
selection rendering and asynchronous completion; progress.ts keeps the factory and
|
||||||
|
its historical helper exports while progress/position.ts, view.ts and interactions.ts
|
||||||
|
separate pointer math, rendering and drag ownership. The remaining simple controls
|
||||||
|
stay individual factories. layer.ts retains the generic registry; contextmenu/index.ts
|
||||||
|
owns positioning and root listeners, while its item factories own their own updates.
|
||||||
|
|
||||||
|
Preserve the observable registry rules: option factories are bare calls with art;
|
||||||
|
click/mounted/beforeUnmount use art as this; index 0 still follows the old index-or-id
|
||||||
|
rule; equal indexes insert before their predecessor. update mutates the cached option
|
||||||
|
before remove, so the newly supplied beforeUnmount runs on the old node. Component
|
||||||
|
add/update may return a div; Control add/update still return undefined. Source types
|
||||||
|
model that distinction; historical public declaration conflicts remain CORE-21 work.
|
||||||
|
A name of `__proto__` now creates a normal own DOM alias without replacing the registry
|
||||||
|
prototype. Other historical aliases and shadowing behavior are not broadly renamed.
|
||||||
|
|
||||||
|
Entry ownership starts before rendering. Failed mounting releases owned effects and
|
||||||
|
removes its DOM/cache/alias without masking the original exception or deleting a
|
||||||
|
successful reentrant replacement. beforeUnmount failure retains the entry for retry;
|
||||||
|
recursive removal of that same entry is ignored. Already detached DOM can be removed.
|
||||||
|
Builtin DOM/Emitter listeners, progress drag handlers and tip timers end on entry
|
||||||
|
removal/update or root destruction; closed handlers are inert even in an Emitter
|
||||||
|
snapshot. Whole-player destruction retains its existing cache/alias and user-hook
|
||||||
|
policy: it does not newly call every beforeUnmount. User mounted hook return values
|
||||||
|
remain ignored. Arbitrary user listeners and third-party SDK work still need explicit
|
||||||
|
user cleanup; internal scopes cannot infer their ownership.
|
||||||
|
|
||||||
|
Selector items keep their non-enumerable, non-configurable getter bindings. A WeakMap
|
||||||
|
allows the same items to bind to a replacement after the prior entry closes; sharing
|
||||||
|
items across simultaneously active controls remains invalid. Each click keeps original
|
||||||
|
item/node/Event/this arguments and synchronous default flags. Only the latest active
|
||||||
|
selection may write a delayed title; removal invalidates pending writes. Background
|
||||||
|
clicks are ignored and owned callback failures are warned with their original value.
|
||||||
|
component/selection.ts links a click's active state to the builtin quality callback,
|
||||||
|
so late source-switch completion cannot overwrite a newer or removed quality notice.
|
||||||
|
Direct callback invocations retain their Promise result and original rejection.
|
||||||
|
Deferred builtin quality installation reports errors without an unhandled Promise.
|
||||||
|
This does not cancel arbitrary user onSelect work or change switchQuality's public
|
||||||
|
settlement contract. Highlight text uses dataset assignment rather than interpolation
|
||||||
|
into HTML attributes; normal text and marker positions retain their behavior.
|
||||||
|
|
||||||
|
controls.less allows groups to wrap inside narrow players. Individual control height
|
||||||
|
continues to use --art-control-height; control/layout.ts observes total layout height
|
||||||
|
through offsetHeight and records --art-controls-height for subtitle/panel offsets.
|
||||||
|
ResizeObserver is preferred; the fallback combines core resize and MutationObserver.
|
||||||
|
Both observers and subscriptions are owned by the bottom controls scope. Without
|
||||||
|
ResizeObserver, unrelated parent CSS resizing is not independently detected until a
|
||||||
|
core resize or control mutation occurs. The 640/320/240px tests include real 16:9
|
||||||
|
heights and verify all buttons remain visible. This does not promise arbitrary custom
|
||||||
|
minimum widths will fit. Narrow setting-tree/panel behavior remains CORE-14 work.
|
||||||
|
|
||||||
|
Use test/component-resources.test.js for ownership, reentry and quality settlement,
|
||||||
|
test/types/components.ts for source contracts, and test/browser/components.spec.js
|
||||||
|
for published/candidate registries, real pointer/contextmenu controls, asynchronous
|
||||||
|
selectors, failures, actual layout and local-media progress. Run the full installed
|
||||||
|
UMD and legacy browser matrices after building, not only the source fixture suite.
|
||||||
|
|
||||||
## Template and public resources
|
## Template and public resources
|
||||||
|
|
||||||
`src/template.ts` owns container checks, reservation, proxy replacement and destruction.
|
`src/template.ts` owns container checks, reservation, proxy replacement and destruction.
|
||||||
@@ -29,7 +97,7 @@ uses the actual HTMLElement shape. This mismatch remains tracked for CORE-21.
|
|||||||
`i18n/index.ts` owns selected language, deep updates and key fallback. Standalone
|
`i18n/index.ts` owns selected language, deep updates and key fallback. Standalone
|
||||||
language modules keep default exports and artplayer-i18n-* aliases through publish.ts.
|
language modules keep default exports and artplayer-i18n-* aliases through publish.ts.
|
||||||
Only own language/message keys participate in lookup; absent prototype names now return
|
Only own language/message keys participate in lookup; absent prototype names now return
|
||||||
the requested string. Explicit custom constructor/toString/**proto** messages remain
|
the requested string. Explicit custom constructor/toString/`__proto__` messages remain
|
||||||
supported. Empty translations still fall back. build:i18n excludes built-in zh-cn and
|
supported. Empty translations still fall back. build:i18n excludes built-in zh-cn and
|
||||||
helper modules, retaining exactly eleven UMD/ESM pairs and their historical globals.
|
helper modules, retaining exactly eleven UMD/ESM pairs and their historical globals.
|
||||||
|
|
||||||
@@ -182,7 +250,7 @@ throw stops that dispatch and propagates unchanged. Off accepts either a once wr
|
|||||||
or its original callback; duplicate registrations of a callback are removed together.
|
or its original callback; duplicate registrations of a callback are removed together.
|
||||||
The once wrapper unsubscribes before invoking user code and now records consumption,
|
The once wrapper unsubscribes before invoking user code and now records consumption,
|
||||||
so a nested dispatch cannot execute a captured copy twice. This fixes a demonstrated
|
so a nested dispatch cannot execute a captured copy twice. This fixes a demonstrated
|
||||||
old defect without changing ordinary snapshot behavior. Event names such as **proto**
|
old defect without changing ordinary snapshot behavior. Event names such as `__proto__`
|
||||||
and toString use own properties rather than inherited Object.prototype values.
|
and toString use own properties rather than inherited Object.prototype values.
|
||||||
|
|
||||||
Shared contracts in test/contracts/emitter.js run against published, workspace and
|
Shared contracts in test/contracts/emitter.js run against published, workspace and
|
||||||
@@ -472,5 +540,5 @@ core files in docs/compiled when committing a shippable core change.
|
|||||||
For new utility behavior, extend the same old/new contract tests. Clearly separate
|
For new utility behavior, extend the same old/new contract tests. Clearly separate
|
||||||
intentional defect corrections from preserved behavior. For timers or Blob URLs,
|
intentional defect corrections from preserved behavior. For timers or Blob URLs,
|
||||||
also identify the owner and verify cleanup in the consuming module. Remaining
|
also identify the owner and verify cleanup in the consuming module. Remaining
|
||||||
constructor, playback, UI, Component and capability migrations are recorded
|
constructor, playback, setting, input and capability migrations are recorded
|
||||||
in refactor/tasks.json and should extend this map as they land.
|
in refactor/tasks.json and should extend this map as they land.
|
||||||
Vendored
+1
-1
@@ -67,4 +67,4 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* 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 a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
|
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var 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
|
||||||
+1
-1
@@ -67,4 +67,4 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* 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,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var 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 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}()),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,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var 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 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}()),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
|
||||||
+743
-536
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ComponentHost, EntryOption } from './types'
|
||||||
|
import { addClass, append, setStyles, tooltip } from '../utils/dom'
|
||||||
|
|
||||||
|
export function renderEntry<Host extends ComponentHost>(element: HTMLDivElement, parent: HTMLElement, kind: string, name: string, id: number, option: EntryOption<Host>): void {
|
||||||
|
addClass(element, `art-${kind}`)
|
||||||
|
addClass(element, `art-${kind}-${name}`)
|
||||||
|
const children = Array.from(parent.children) as HTMLElement[]
|
||||||
|
element.dataset.index = String(option.index || id)
|
||||||
|
const next = children.find(child => Number(child.dataset.index) >= Number(element.dataset.index))
|
||||||
|
if (next)
|
||||||
|
next.insertAdjacentElement('beforebegin', element)
|
||||||
|
else
|
||||||
|
append(parent, element)
|
||||||
|
if (option.html)
|
||||||
|
append(element, option.html)
|
||||||
|
if (option.style)
|
||||||
|
setStyles(element, option.style)
|
||||||
|
if (option.tooltip)
|
||||||
|
tooltip(element, option.tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builtin renderers append known element markup or existing icon wrappers.
|
||||||
|
export function appendElement(parent: Element, child: string | Element): HTMLElement {
|
||||||
|
return append(parent, child) as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryElement(selector: string, parent: ParentNode): HTMLElement {
|
||||||
|
return parent.querySelector<HTMLElement>(selector)!
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type ResourceScope from '../lifecycle/scope'
|
||||||
|
import type { ComponentHost } from './types'
|
||||||
|
import { getScope } from '../lifecycle/instance'
|
||||||
|
|
||||||
|
const scopes = new WeakMap<HTMLElement, ResourceScope>()
|
||||||
|
|
||||||
|
export function ownEntry(art: ComponentHost, element: HTMLElement): ResourceScope {
|
||||||
|
const scope = getScope(art).child()
|
||||||
|
scopes.set(element, scope)
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryScope(element: HTMLElement): ResourceScope {
|
||||||
|
const scope = scopes.get(element)
|
||||||
|
if (!scope)
|
||||||
|
throw new Error('ArtPlayer component has not been registered')
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseEntry(element: HTMLElement): void {
|
||||||
|
scopes.get(element)?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxyEntry<Name extends keyof HTMLElementEventMap>(art: ComponentHost, element: HTMLElement, target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): () => void {
|
||||||
|
const scope = entryScope(element)
|
||||||
|
if (scope.closed)
|
||||||
|
return () => {}
|
||||||
|
const cleanup = art.events.proxy(target, name, (event) => {
|
||||||
|
if (!scope.closed)
|
||||||
|
return callback(event as HTMLElementEventMap[Name])
|
||||||
|
})
|
||||||
|
return scope.add(() => {
|
||||||
|
art.events.remove(cleanup)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionHost<Events extends { [Name in keyof Events]: readonly unknown[] }> {
|
||||||
|
on: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
|
||||||
|
off: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeEntry<Events extends { [Name in keyof Events]: readonly unknown[] }, Name extends keyof Events>(art: SubscriptionHost<Events>, element: HTMLElement, name: Name, callback: (...args: [...Events[Name]]) => unknown): void {
|
||||||
|
const scope = entryScope(element)
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
const guarded = (...args: [...Events[Name]]) => {
|
||||||
|
if (!scope.closed)
|
||||||
|
return callback(...args)
|
||||||
|
}
|
||||||
|
art.on(name, guarded)
|
||||||
|
scope.add(() => {
|
||||||
|
art.off(name, guarded)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Cleanup } from '../lifecycle/scope'
|
||||||
|
|
||||||
|
const selections = new WeakMap<Event, () => boolean>()
|
||||||
|
|
||||||
|
export function trackSelection(event: Event, active: () => boolean): Cleanup {
|
||||||
|
selections.set(event, active)
|
||||||
|
return () => {
|
||||||
|
if (selections.get(event) === active)
|
||||||
|
selections.delete(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureSelection(event?: Event): () => boolean {
|
||||||
|
return (event && selections.get(event)) || (() => true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type Component from '../utils/component'
|
||||||
|
|
||||||
|
export type EventCleanup = () => void
|
||||||
|
|
||||||
|
export interface ComponentHost {
|
||||||
|
template: { $player: HTMLElement }
|
||||||
|
emit: (name: string, ...args: unknown[]) => unknown
|
||||||
|
events: {
|
||||||
|
proxy: (target: EventTarget, name: string, callback: (event: Event) => unknown) => EventCleanup
|
||||||
|
remove: (cleanup: EventCleanup) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectorItem {
|
||||||
|
html: string | HTMLElement | number
|
||||||
|
value?: string | number
|
||||||
|
default?: boolean
|
||||||
|
readonly $control_option?: SelectorItem[]
|
||||||
|
readonly $control_item?: HTMLDivElement
|
||||||
|
readonly $control_value?: HTMLDivElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryOption<Host extends ComponentHost> {
|
||||||
|
html?: string | HTMLElement | number
|
||||||
|
disable?: boolean
|
||||||
|
name?: string
|
||||||
|
index?: number
|
||||||
|
style?: Partial<CSSStyleDeclaration>
|
||||||
|
tooltip?: string | number
|
||||||
|
position?: string
|
||||||
|
selector?: SelectorItem[]
|
||||||
|
click?: (this: Host, component: Component<Host>, event: Event) => unknown
|
||||||
|
mounted?: (this: Host, element: HTMLDivElement) => unknown
|
||||||
|
beforeUnmount?: (this: Host, element: HTMLDivElement) => unknown
|
||||||
|
onSelect?: (this: Host, item: SelectorItem, element: HTMLDivElement, event: Event) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntryInput<Host extends ComponentHost> = EntryOption<Host> | ((art: Host) => EntryOption<Host>)
|
||||||
|
|
||||||
|
export interface Entry<Host extends ComponentHost> {
|
||||||
|
$ref: HTMLDivElement
|
||||||
|
events: EventCleanup[]
|
||||||
|
option: EntryOption<Host>
|
||||||
|
}
|
||||||
+8
-5
@@ -1,6 +1,8 @@
|
|||||||
import { inverseClass, query, queryAll } from '../utils'
|
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||||
|
import { controlEvents } from '../control/resources'
|
||||||
|
import { inverseClass, query } from '../utils'
|
||||||
|
|
||||||
export default function aspectRatio(option) {
|
export default function aspectRatio(option: ContextmenuOption): ContextmenuFactory {
|
||||||
return (art) => {
|
return (art) => {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
@@ -15,19 +17,20 @@ export default function aspectRatio(option) {
|
|||||||
...option,
|
...option,
|
||||||
html: `${i18n.get('Aspect Ratio')}: ${html}`,
|
html: `${i18n.get('Aspect Ratio')}: ${html}`,
|
||||||
click: (contextmenu, event) => {
|
click: (contextmenu, event) => {
|
||||||
const { value } = event.target.dataset
|
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
|
||||||
if (value) {
|
if (value) {
|
||||||
art.aspectRatio = value
|
art.aspectRatio = value
|
||||||
contextmenu.show = false
|
contextmenu.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted: ($panel) => {
|
mounted: ($panel) => {
|
||||||
|
const { on } = controlEvents(art, $panel)
|
||||||
const $default = query('[data-value="default"]', $panel)
|
const $default = query('[data-value="default"]', $panel)
|
||||||
if ($default) {
|
if ($default) {
|
||||||
inverseClass($default, 'art-current')
|
inverseClass($default, 'art-current')
|
||||||
}
|
}
|
||||||
art.on('aspectRatio', (value) => {
|
on('aspectRatio', (value) => {
|
||||||
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
|
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
|
||||||
if ($current) {
|
if ($current) {
|
||||||
inverseClass($current, 'art-current')
|
inverseClass($current, 'art-current')
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export default function close(option) {
|
|
||||||
return art => ({
|
|
||||||
...option,
|
|
||||||
html: art.i18n.get('Close'),
|
|
||||||
click: (contextmenu) => {
|
|
||||||
contextmenu.show = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||||
|
|
||||||
|
export default function close(option: ContextmenuOption): ContextmenuFactory {
|
||||||
|
return art => ({
|
||||||
|
...option,
|
||||||
|
html: art.i18n.get('Close'),
|
||||||
|
click: (contextmenu) => {
|
||||||
|
contextmenu.show = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+8
-5
@@ -1,6 +1,8 @@
|
|||||||
import { capitalize, inverseClass, query, queryAll } from '../utils'
|
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||||
|
import { controlEvents } from '../control/resources'
|
||||||
|
import { capitalize, inverseClass, query } from '../utils'
|
||||||
|
|
||||||
export default function flip(option) {
|
export default function flip(option: ContextmenuOption): ContextmenuFactory {
|
||||||
return (art) => {
|
return (art) => {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
@@ -13,19 +15,20 @@ export default function flip(option) {
|
|||||||
...option,
|
...option,
|
||||||
html: `${i18n.get('Video Flip')}: ${html}`,
|
html: `${i18n.get('Video Flip')}: ${html}`,
|
||||||
click: (contextmenu, event) => {
|
click: (contextmenu, event) => {
|
||||||
const { value } = event.target.dataset
|
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
|
||||||
if (value) {
|
if (value) {
|
||||||
art.flip = value.toLowerCase()
|
art.flip = value.toLowerCase()
|
||||||
contextmenu.show = false
|
contextmenu.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted: ($panel) => {
|
mounted: ($panel) => {
|
||||||
|
const { on } = controlEvents(art, $panel)
|
||||||
const $default = query('[data-value="normal"]', $panel)
|
const $default = query('[data-value="normal"]', $panel)
|
||||||
if ($default) {
|
if ($default) {
|
||||||
inverseClass($default, 'art-current')
|
inverseClass($default, 'art-current')
|
||||||
}
|
}
|
||||||
art.on('flip', (value) => {
|
on('flip', (value) => {
|
||||||
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
|
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
|
||||||
if ($current) {
|
if ($current) {
|
||||||
inverseClass($current, 'art-current')
|
inverseClass($current, 'art-current')
|
||||||
}
|
}
|
||||||
+10
-6
@@ -1,3 +1,6 @@
|
|||||||
|
import type { ContextmenuHost } from './types'
|
||||||
|
import { ownEntry } from '../component/resources'
|
||||||
|
import { controlEvents } from '../control/resources'
|
||||||
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
|
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
|
||||||
import Component from '../utils/component'
|
import Component from '../utils/component'
|
||||||
import aspectRatio from './aspectRatio'
|
import aspectRatio from './aspectRatio'
|
||||||
@@ -7,24 +10,25 @@ import info from './info'
|
|||||||
import playbackRate from './playbackRate'
|
import playbackRate from './playbackRate'
|
||||||
import version from './version'
|
import version from './version'
|
||||||
|
|
||||||
export default class Contextmenu extends Component {
|
export default class Contextmenu extends Component<ContextmenuHost> {
|
||||||
constructor(art) {
|
constructor(art: ContextmenuHost) {
|
||||||
super(art)
|
super(art)
|
||||||
|
|
||||||
this.name = 'contextmenu'
|
this.name = 'contextmenu'
|
||||||
this.$parent = art.template.$contextmenu
|
this.$parent = art.template.$contextmenu
|
||||||
|
ownEntry(art, this.$parent)
|
||||||
|
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init(): void {
|
||||||
const {
|
const {
|
||||||
option,
|
option,
|
||||||
proxy,
|
|
||||||
template: { $player, $contextmenu },
|
template: { $player, $contextmenu },
|
||||||
} = this.art
|
} = this.art
|
||||||
|
const { on, proxy } = controlEvents(this.art, $contextmenu)
|
||||||
|
|
||||||
if (option.playbackRate) {
|
if (option.playbackRate) {
|
||||||
this.add(
|
this.add(
|
||||||
@@ -75,7 +79,7 @@ export default class Contextmenu extends Component {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (let index = 0; index < option.contextmenu.length; index++) {
|
for (let index = 0; index < option.contextmenu.length; index++) {
|
||||||
this.add(option.contextmenu[index])
|
this.add(option.contextmenu[index]!)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy($player, 'contextmenu', (event) => {
|
proxy($player, 'contextmenu', (event) => {
|
||||||
@@ -112,7 +116,7 @@ export default class Contextmenu extends Component {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.art.on('blur', () => {
|
on('blur', () => {
|
||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
export default function info(option) {
|
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||||
|
|
||||||
|
export default function info(option: ContextmenuOption): ContextmenuFactory {
|
||||||
return art => ({
|
return art => ({
|
||||||
...option,
|
...option,
|
||||||
html: art.i18n.get('Video Info'),
|
html: art.i18n.get('Video Info'),
|
||||||
+8
-5
@@ -1,6 +1,8 @@
|
|||||||
import { inverseClass, query, queryAll } from '../utils'
|
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||||
|
import { controlEvents } from '../control/resources'
|
||||||
|
import { inverseClass, query } from '../utils'
|
||||||
|
|
||||||
export default function playbackRate(option) {
|
export default function playbackRate(option: ContextmenuOption): ContextmenuFactory {
|
||||||
return (art) => {
|
return (art) => {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
@@ -15,18 +17,19 @@ export default function playbackRate(option) {
|
|||||||
...option,
|
...option,
|
||||||
html: `${i18n.get('Play Speed')}: ${html}`,
|
html: `${i18n.get('Play Speed')}: ${html}`,
|
||||||
click: (contextmenu, event) => {
|
click: (contextmenu, event) => {
|
||||||
const { value } = event.target.dataset
|
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
|
||||||
if (value) {
|
if (value) {
|
||||||
art.playbackRate = Number(value)
|
art.playbackRate = Number(value)
|
||||||
contextmenu.show = false
|
contextmenu.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted: ($panel) => {
|
mounted: ($panel) => {
|
||||||
|
const { on } = controlEvents(art, $panel)
|
||||||
const $default = query('[data-value="1"]', $panel)
|
const $default = query('[data-value="1"]', $panel)
|
||||||
if ($default)
|
if ($default)
|
||||||
inverseClass($default, 'art-current')
|
inverseClass($default, 'art-current')
|
||||||
art.on('video:ratechange', () => {
|
on('video:ratechange', () => {
|
||||||
const $current = queryAll('span', $panel).find(
|
const $current = Array.from($panel.querySelectorAll('span')).find(
|
||||||
item => Number(item.dataset.value) === art.playbackRate,
|
item => Number(item.dataset.value) === art.playbackRate,
|
||||||
)
|
)
|
||||||
if ($current) {
|
if ($current) {
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { SubscriptionHost } from '../component/resources'
|
||||||
|
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
|
||||||
|
import type { UIEvents } from '../control/types'
|
||||||
|
|
||||||
|
export interface ContextmenuHost extends ComponentHost, SubscriptionHost<UIEvents> {
|
||||||
|
template: { $player: HTMLDivElement, $contextmenu: HTMLDivElement }
|
||||||
|
option: { playbackRate: boolean, aspectRatio: boolean, flip: boolean, contextmenu: EntryInput<ContextmenuHost>[] }
|
||||||
|
constructor: { CONTEXTMENU: boolean, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
|
||||||
|
i18n: { get: (key: string) => string }
|
||||||
|
info: { show: boolean }
|
||||||
|
playbackRate: number
|
||||||
|
aspectRatio: string
|
||||||
|
flip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextmenuOption = EntryOption<ContextmenuHost>
|
||||||
|
export type ContextmenuFactory = (art: ContextmenuHost) => ContextmenuOption
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
|
import type { ContextmenuOption } from './types'
|
||||||
import { version } from '../../package.json'
|
import { version } from '../../package.json'
|
||||||
|
|
||||||
export default function (option) {
|
export default function (option: ContextmenuOption): ContextmenuOption {
|
||||||
return {
|
return {
|
||||||
...option,
|
...option,
|
||||||
html: `<a href="https://artplayer.org" target="_blank" style="width:100%;">ArtPlayer ${version}</a>`,
|
html: `<a href="https://artplayer.org" target="_blank" style="width:100%;">ArtPlayer ${version}</a>`,
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { append } from '../utils'
|
|
||||||
|
|
||||||
export default function airplay(option) {
|
|
||||||
return art => ({
|
|
||||||
...option,
|
|
||||||
tooltip: art.i18n.get('AirPlay'),
|
|
||||||
mounted: ($control) => {
|
|
||||||
const { proxy, icons } = art
|
|
||||||
append($control, icons.airplay)
|
|
||||||
proxy($control, 'click', () => art.airplay())
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
|
export default function airplay(option: ControlOption): ControlFactory {
|
||||||
|
return art => ({
|
||||||
|
...option,
|
||||||
|
tooltip: art.i18n.get('AirPlay'),
|
||||||
|
mounted: ($control) => {
|
||||||
|
const { proxy } = controlEvents(art, $control)
|
||||||
|
const { icons } = art
|
||||||
|
appendElement($control, icons.airplay)
|
||||||
|
proxy($control, 'click', () => art.airplay())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { EntryInput } from '../component/types'
|
||||||
|
import type { ControlHost } from './types'
|
||||||
|
import { getScope } from '../lifecycle/instance'
|
||||||
|
import { wait } from '../lifecycle/resources'
|
||||||
|
import { isMobile } from '../utils'
|
||||||
|
import airplay from './airplay'
|
||||||
|
import fullscreen from './fullscreen'
|
||||||
|
import fullscreenWeb from './fullscreenWeb'
|
||||||
|
import pip from './pip'
|
||||||
|
import playAndPause from './playAndPause'
|
||||||
|
import progress from './progress'
|
||||||
|
import screenshot from './screenshot'
|
||||||
|
import setting from './setting'
|
||||||
|
import time from './time'
|
||||||
|
import volume from './volume'
|
||||||
|
|
||||||
|
export function installControls(controls: { art: ControlHost, add: (option: EntryInput<ControlHost>) => undefined }): void {
|
||||||
|
const { option } = controls.art
|
||||||
|
|
||||||
|
if (!option.isLive) {
|
||||||
|
controls.add(
|
||||||
|
progress({
|
||||||
|
name: 'progress',
|
||||||
|
position: 'top',
|
||||||
|
index: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.add({
|
||||||
|
name: 'thumbnails',
|
||||||
|
position: 'top',
|
||||||
|
index: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
controls.add(
|
||||||
|
playAndPause({
|
||||||
|
name: 'playAndPause',
|
||||||
|
position: 'left',
|
||||||
|
index: 10,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
controls.add(
|
||||||
|
volume({
|
||||||
|
name: 'volume',
|
||||||
|
position: 'left',
|
||||||
|
index: 20,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!option.isLive) {
|
||||||
|
controls.add(
|
||||||
|
time({
|
||||||
|
name: 'time',
|
||||||
|
position: 'left',
|
||||||
|
index: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.quality.length) {
|
||||||
|
wait(getScope(controls.art)).then((active) => {
|
||||||
|
if (!active || getScope(controls.art).closed)
|
||||||
|
return
|
||||||
|
controls.art.quality = option.quality
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('ArtPlayer quality initialization failed:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.screenshot && !isMobile) {
|
||||||
|
controls.add(
|
||||||
|
screenshot({
|
||||||
|
name: 'screenshot',
|
||||||
|
position: 'right',
|
||||||
|
index: 20,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.setting) {
|
||||||
|
controls.add(
|
||||||
|
setting({
|
||||||
|
name: 'setting',
|
||||||
|
position: 'right',
|
||||||
|
index: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.pip) {
|
||||||
|
controls.add(
|
||||||
|
pip({
|
||||||
|
name: 'pip',
|
||||||
|
position: 'right',
|
||||||
|
index: 40,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.airplay && 'WebKitPlaybackTargetAvailabilityEvent' in window && window.WebKitPlaybackTargetAvailabilityEvent) {
|
||||||
|
controls.add(
|
||||||
|
airplay({
|
||||||
|
name: 'airplay',
|
||||||
|
position: 'right',
|
||||||
|
index: 50,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.fullscreenWeb) {
|
||||||
|
controls.add(
|
||||||
|
fullscreenWeb({
|
||||||
|
name: 'fullscreenWeb',
|
||||||
|
position: 'right',
|
||||||
|
index: 60,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.fullscreen) {
|
||||||
|
controls.add(
|
||||||
|
fullscreen({
|
||||||
|
name: 'fullscreen',
|
||||||
|
position: 'right',
|
||||||
|
index: 70,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < option.controls.length; index++) {
|
||||||
|
controls.add(option.controls[index]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-6
@@ -1,21 +1,25 @@
|
|||||||
import { append, setStyle, tooltip } from '../utils'
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { setStyle, tooltip } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
export default function fullscreen(option) {
|
export default function fullscreen(option: ControlOption): ControlFactory {
|
||||||
return art => ({
|
return art => ({
|
||||||
...option,
|
...option,
|
||||||
tooltip: art.i18n.get('Fullscreen'),
|
tooltip: art.i18n.get('Fullscreen'),
|
||||||
mounted: ($control) => {
|
mounted: ($control) => {
|
||||||
const { proxy, icons, i18n } = art
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
const { icons, i18n } = art
|
||||||
|
|
||||||
const $fullscreenOn = append($control, icons.fullscreenOn)
|
const $fullscreenOn = appendElement($control, icons.fullscreenOn)
|
||||||
const $fullscreenOff = append($control, icons.fullscreenOff)
|
const $fullscreenOff = appendElement($control, icons.fullscreenOff)
|
||||||
setStyle($fullscreenOff, 'display', 'none')
|
setStyle($fullscreenOff, 'display', 'none')
|
||||||
|
|
||||||
proxy($control, 'click', () => {
|
proxy($control, 'click', () => {
|
||||||
art.fullscreen = !art.fullscreen
|
art.fullscreen = !art.fullscreen
|
||||||
})
|
})
|
||||||
|
|
||||||
art.on('fullscreen', (state) => {
|
on('fullscreen', (state) => {
|
||||||
if (state) {
|
if (state) {
|
||||||
tooltip($control, i18n.get('Exit Fullscreen'))
|
tooltip($control, i18n.get('Exit Fullscreen'))
|
||||||
setStyle($fullscreenOn, 'display', 'none')
|
setStyle($fullscreenOn, 'display', 'none')
|
||||||
+10
-6
@@ -1,21 +1,25 @@
|
|||||||
import { append, setStyle, tooltip } from '../utils'
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { setStyle, tooltip } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
export default function fullscreenWeb(option) {
|
export default function fullscreenWeb(option: ControlOption): ControlFactory {
|
||||||
return art => ({
|
return art => ({
|
||||||
...option,
|
...option,
|
||||||
tooltip: art.i18n.get('Web Fullscreen'),
|
tooltip: art.i18n.get('Web Fullscreen'),
|
||||||
mounted: ($control) => {
|
mounted: ($control) => {
|
||||||
const { proxy, icons, i18n } = art
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
const { icons, i18n } = art
|
||||||
|
|
||||||
const $fullscreenWebOn = append($control, icons.fullscreenWebOn)
|
const $fullscreenWebOn = appendElement($control, icons.fullscreenWebOn)
|
||||||
const $fullscreenWebOff = append($control, icons.fullscreenWebOff)
|
const $fullscreenWebOff = appendElement($control, icons.fullscreenWebOff)
|
||||||
setStyle($fullscreenWebOff, 'display', 'none')
|
setStyle($fullscreenWebOff, 'display', 'none')
|
||||||
|
|
||||||
proxy($control, 'click', () => {
|
proxy($control, 'click', () => {
|
||||||
art.fullscreenWeb = !art.fullscreenWeb
|
art.fullscreenWeb = !art.fullscreenWeb
|
||||||
})
|
})
|
||||||
|
|
||||||
art.on('fullscreenWeb', (value) => {
|
on('fullscreenWeb', (value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
tooltip($control, i18n.get('Exit Web Fullscreen'))
|
tooltip($control, i18n.get('Exit Web Fullscreen'))
|
||||||
setStyle($fullscreenWebOn, 'display', 'none')
|
setStyle($fullscreenWebOn, 'display', 'none')
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import { getScope } from '../lifecycle/instance'
|
|
||||||
import { wait } from '../lifecycle/resources'
|
|
||||||
import {
|
|
||||||
addClass,
|
|
||||||
append,
|
|
||||||
createElement,
|
|
||||||
def,
|
|
||||||
errorHandle,
|
|
||||||
getComposedPath,
|
|
||||||
includeFromEvent,
|
|
||||||
inverseClass,
|
|
||||||
isMobile,
|
|
||||||
removeClass,
|
|
||||||
} from '../utils'
|
|
||||||
import Component from '../utils/component'
|
|
||||||
import airplay from './airplay'
|
|
||||||
import fullscreen from './fullscreen'
|
|
||||||
import fullscreenWeb from './fullscreenWeb'
|
|
||||||
import pip from './pip'
|
|
||||||
import playAndPause from './playAndPause'
|
|
||||||
import progress from './progress'
|
|
||||||
import screenshot from './screenshot'
|
|
||||||
import setting from './setting'
|
|
||||||
import time from './time'
|
|
||||||
import volume from './volume'
|
|
||||||
|
|
||||||
export default class Control extends Component {
|
|
||||||
constructor(art) {
|
|
||||||
super(art)
|
|
||||||
|
|
||||||
this.isHover = false
|
|
||||||
this.name = 'control'
|
|
||||||
this.timer = Date.now()
|
|
||||||
|
|
||||||
const { constructor } = art
|
|
||||||
const { $player, $bottom } = this.art.template
|
|
||||||
|
|
||||||
art.on('mousemove', () => {
|
|
||||||
if (!isMobile) {
|
|
||||||
this.show = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('click', () => {
|
|
||||||
if (isMobile) {
|
|
||||||
this.toggle()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.show = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('document:mousemove', (event) => {
|
|
||||||
this.isHover = includeFromEvent(event, $bottom)
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('video:timeupdate', () => {
|
|
||||||
if (
|
|
||||||
!art.setting.show
|
|
||||||
&& !this.isHover
|
|
||||||
&& !art.isInput
|
|
||||||
&& art.playing
|
|
||||||
&& this.show
|
|
||||||
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
|
|
||||||
) {
|
|
||||||
this.show = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('control', (state) => {
|
|
||||||
if (state) {
|
|
||||||
removeClass($player, 'art-hide-cursor')
|
|
||||||
addClass($player, 'art-hover')
|
|
||||||
this.timer = Date.now()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
addClass($player, 'art-hide-cursor')
|
|
||||||
removeClass($player, 'art-hover')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
const { option } = this.art
|
|
||||||
|
|
||||||
if (!option.isLive) {
|
|
||||||
this.add(
|
|
||||||
progress({
|
|
||||||
name: 'progress',
|
|
||||||
position: 'top',
|
|
||||||
index: 10,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.add({
|
|
||||||
name: 'thumbnails',
|
|
||||||
position: 'top',
|
|
||||||
index: 20,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.add(
|
|
||||||
playAndPause({
|
|
||||||
name: 'playAndPause',
|
|
||||||
position: 'left',
|
|
||||||
index: 10,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
this.add(
|
|
||||||
volume({
|
|
||||||
name: 'volume',
|
|
||||||
position: 'left',
|
|
||||||
index: 20,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!option.isLive) {
|
|
||||||
this.add(
|
|
||||||
time({
|
|
||||||
name: 'time',
|
|
||||||
position: 'left',
|
|
||||||
index: 30,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.quality.length) {
|
|
||||||
wait(getScope(this.art)).then((active) => {
|
|
||||||
if (!active || getScope(this.art).closed)
|
|
||||||
return
|
|
||||||
this.art.quality = option.quality
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.screenshot && !isMobile) {
|
|
||||||
this.add(
|
|
||||||
screenshot({
|
|
||||||
name: 'screenshot',
|
|
||||||
position: 'right',
|
|
||||||
index: 20,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.setting) {
|
|
||||||
this.add(
|
|
||||||
setting({
|
|
||||||
name: 'setting',
|
|
||||||
position: 'right',
|
|
||||||
index: 30,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.pip) {
|
|
||||||
this.add(
|
|
||||||
pip({
|
|
||||||
name: 'pip',
|
|
||||||
position: 'right',
|
|
||||||
index: 40,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.airplay && window.WebKitPlaybackTargetAvailabilityEvent) {
|
|
||||||
this.add(
|
|
||||||
airplay({
|
|
||||||
name: 'airplay',
|
|
||||||
position: 'right',
|
|
||||||
index: 50,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.fullscreenWeb) {
|
|
||||||
this.add(
|
|
||||||
fullscreenWeb({
|
|
||||||
name: 'fullscreenWeb',
|
|
||||||
position: 'right',
|
|
||||||
index: 60,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.fullscreen) {
|
|
||||||
this.add(
|
|
||||||
fullscreen({
|
|
||||||
name: 'fullscreen',
|
|
||||||
position: 'right',
|
|
||||||
index: 70,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < option.controls.length; index++) {
|
|
||||||
this.add(option.controls[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
add(getOption) {
|
|
||||||
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
|
|
||||||
const { $progress, $controlsLeft, $controlsRight } = this.art.template
|
|
||||||
|
|
||||||
switch (option.position) {
|
|
||||||
case 'top':
|
|
||||||
this.$parent = $progress
|
|
||||||
break
|
|
||||||
case 'left':
|
|
||||||
this.$parent = $controlsLeft
|
|
||||||
break
|
|
||||||
case 'right':
|
|
||||||
this.$parent = $controlsRight
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
super.add(option)
|
|
||||||
}
|
|
||||||
|
|
||||||
check(target) {
|
|
||||||
if (!target) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target.$control_value.innerHTML = target.html
|
|
||||||
for (let index = 0; index < target.$control_option.length; index++) {
|
|
||||||
const item = target.$control_option[index]
|
|
||||||
item.default = item === target
|
|
||||||
if (item.default) {
|
|
||||||
inverseClass(item.$control_item, 'art-current')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selector(option, $ref, events) {
|
|
||||||
const { proxy } = this.art.events
|
|
||||||
|
|
||||||
addClass($ref, 'art-control-selector')
|
|
||||||
const $value = createElement('div')
|
|
||||||
addClass($value, 'art-selector-value')
|
|
||||||
append($value, option.html)
|
|
||||||
$ref.textContent = ''
|
|
||||||
append($ref, $value)
|
|
||||||
|
|
||||||
const $list = createElement('div')
|
|
||||||
addClass($list, 'art-selector-list')
|
|
||||||
append($ref, $list)
|
|
||||||
|
|
||||||
for (let index = 0; index < option.selector.length; index++) {
|
|
||||||
const item = option.selector[index]
|
|
||||||
const $item = createElement('div')
|
|
||||||
addClass($item, 'art-selector-item')
|
|
||||||
if (item.default)
|
|
||||||
addClass($item, 'art-current')
|
|
||||||
$item.dataset.index = index
|
|
||||||
$item.dataset.value = item.value
|
|
||||||
$item.innerHTML = item.html
|
|
||||||
append($list, $item)
|
|
||||||
|
|
||||||
def(item, '$control_option', {
|
|
||||||
get: () => option.selector,
|
|
||||||
})
|
|
||||||
|
|
||||||
def(item, '$control_item', {
|
|
||||||
get: () => $item,
|
|
||||||
})
|
|
||||||
|
|
||||||
def(item, '$control_value', {
|
|
||||||
get: () => $value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = proxy($list, 'click', async (event) => {
|
|
||||||
const path = getComposedPath(event)
|
|
||||||
const item = option.selector.find(
|
|
||||||
item => item.$control_item === path.find($item => item.$control_item === $item),
|
|
||||||
)
|
|
||||||
this.check(item)
|
|
||||||
if (option.onSelect) {
|
|
||||||
$value.innerHTML = await option.onSelect.call(this.art, item, item.$control_item, event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
events.push(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { EntryInput, EntryOption, EventCleanup, SelectorItem } from '../component/types'
|
||||||
|
import type { ControlHost } from './types'
|
||||||
|
import { ownEntry } from '../component/resources'
|
||||||
|
import { getScope, isClosing } from '../lifecycle/instance'
|
||||||
|
import {
|
||||||
|
addClass,
|
||||||
|
errorHandle,
|
||||||
|
includeFromEvent,
|
||||||
|
isMobile,
|
||||||
|
removeClass,
|
||||||
|
} from '../utils'
|
||||||
|
import Component from '../utils/component'
|
||||||
|
import { installControls } from './builtins'
|
||||||
|
import { observeControlLayout } from './layout'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
import { checkSelector, renderSelector } from './selector'
|
||||||
|
|
||||||
|
export default class Control extends Component<ControlHost> {
|
||||||
|
declare isHover: boolean
|
||||||
|
declare timer: number
|
||||||
|
|
||||||
|
constructor(art: ControlHost) {
|
||||||
|
super(art)
|
||||||
|
|
||||||
|
this.isHover = false
|
||||||
|
this.name = 'control'
|
||||||
|
this.timer = Date.now()
|
||||||
|
|
||||||
|
const { constructor } = art
|
||||||
|
const { $player, $bottom } = this.art.template
|
||||||
|
ownEntry(art, $bottom)
|
||||||
|
const { on } = controlEvents(art, $bottom)
|
||||||
|
|
||||||
|
on('mousemove', () => {
|
||||||
|
if (!isMobile) {
|
||||||
|
this.show = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
on('click', () => {
|
||||||
|
if (isMobile) {
|
||||||
|
this.toggle()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.show = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
on('document:mousemove', (event) => {
|
||||||
|
this.isHover = includeFromEvent(event, $bottom)
|
||||||
|
})
|
||||||
|
|
||||||
|
on('video:timeupdate', () => {
|
||||||
|
if (
|
||||||
|
!art.setting.show
|
||||||
|
&& !this.isHover
|
||||||
|
&& !art.isInput
|
||||||
|
&& art.playing
|
||||||
|
&& this.show
|
||||||
|
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
|
||||||
|
) {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
on('control', (state) => {
|
||||||
|
if (state) {
|
||||||
|
removeClass($player, 'art-hide-cursor')
|
||||||
|
addClass($player, 'art-hover')
|
||||||
|
this.timer = Date.now()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addClass($player, 'art-hide-cursor')
|
||||||
|
removeClass($player, 'art-hover')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.init()
|
||||||
|
if (!getScope(art).closed)
|
||||||
|
observeControlLayout(art)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
installControls(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override add(getOption: EntryInput<ControlHost>): undefined {
|
||||||
|
if (isClosing(this.art))
|
||||||
|
return
|
||||||
|
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
|
||||||
|
const { $progress, $controlsLeft, $controlsRight } = this.art.template
|
||||||
|
|
||||||
|
switch (option.position) {
|
||||||
|
case 'top':
|
||||||
|
this.$parent = $progress
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
this.$parent = $controlsLeft
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
this.$parent = $controlsRight
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
super.add(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
check(target?: SelectorItem): void {
|
||||||
|
checkSelector(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
override selector(option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
|
||||||
|
renderSelector(this.art, target => this.check(target), option, $ref, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ControlHost } from './types'
|
||||||
|
import { entryScope } from '../component/resources'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
|
export function observeControlLayout(art: ControlHost): void {
|
||||||
|
const { $bottom, $controls, $player } = art.template
|
||||||
|
const scope = entryScope($bottom)
|
||||||
|
const { on } = controlEvents(art, $bottom)
|
||||||
|
const update = () => {
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
// Layout height is unaffected by the player's rotation/scale transform.
|
||||||
|
const height = $controls.offsetHeight
|
||||||
|
if (height > 0 && $player.style.getPropertyValue('--art-controls-height') !== `${height}px`)
|
||||||
|
$player.style.setProperty('--art-controls-height', `${height}px`)
|
||||||
|
}
|
||||||
|
on('resize', update)
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const observer = new ResizeObserver(update)
|
||||||
|
observer.observe($controls)
|
||||||
|
scope.add(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (typeof MutationObserver !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(update)
|
||||||
|
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
|
||||||
|
scope.add(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { append, tooltip } from '../utils'
|
|
||||||
|
|
||||||
export default function pip(option) {
|
|
||||||
return art => ({
|
|
||||||
...option,
|
|
||||||
tooltip: art.i18n.get('PIP Mode'),
|
|
||||||
mounted: ($control) => {
|
|
||||||
const { proxy, icons, i18n } = art
|
|
||||||
|
|
||||||
append($control, icons.pip)
|
|
||||||
|
|
||||||
proxy($control, 'click', () => {
|
|
||||||
art.pip = !art.pip
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('pip', (value) => {
|
|
||||||
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { tooltip } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
|
export default function pip(option: ControlOption): ControlFactory {
|
||||||
|
return art => ({
|
||||||
|
...option,
|
||||||
|
tooltip: art.i18n.get('PIP Mode'),
|
||||||
|
mounted: ($control) => {
|
||||||
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
const { icons, i18n } = art
|
||||||
|
|
||||||
|
appendElement($control, icons.pip)
|
||||||
|
|
||||||
|
proxy($control, 'click', () => {
|
||||||
|
art.pip = !art.pip
|
||||||
|
})
|
||||||
|
|
||||||
|
on('pip', (value) => {
|
||||||
|
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+11
-7
@@ -1,13 +1,17 @@
|
|||||||
import { append, setStyle, silencePromise, tooltip } from '../utils'
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { setStyle, silencePromise, tooltip } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
export default function playAndPause(option) {
|
export default function playAndPause(option: ControlOption): ControlFactory {
|
||||||
return art => ({
|
return art => ({
|
||||||
...option,
|
...option,
|
||||||
mounted: ($control) => {
|
mounted: ($control) => {
|
||||||
const { proxy, icons, i18n } = art
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
const { icons, i18n } = art
|
||||||
|
|
||||||
const $play = append($control, icons.play)
|
const $play = appendElement($control, icons.play)
|
||||||
const $pause = append($control, icons.pause)
|
const $pause = appendElement($control, icons.pause)
|
||||||
tooltip($play, i18n.get('Play'))
|
tooltip($play, i18n.get('Play'))
|
||||||
tooltip($pause, i18n.get('Pause'))
|
tooltip($pause, i18n.get('Pause'))
|
||||||
|
|
||||||
@@ -36,11 +40,11 @@ export default function playAndPause(option) {
|
|||||||
showPlay()
|
showPlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
art.on('video:playing', () => {
|
on('video:playing', () => {
|
||||||
showPause()
|
showPause()
|
||||||
})
|
})
|
||||||
|
|
||||||
art.on('video:pause', () => {
|
on('video:pause', () => {
|
||||||
showPlay()
|
showPlay()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import { addClass, append, clamp, getRect, includeFromEvent, isMobile, query, removeClass, secondToTime, setStyle } from '../utils'
|
|
||||||
|
|
||||||
export function getPosFromEvent(art, event) {
|
|
||||||
const { $progress } = art.template
|
|
||||||
const { left } = getRect($progress)
|
|
||||||
const eventLeft = isMobile ? event.touches[0].clientX : event.clientX
|
|
||||||
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
|
|
||||||
const second = (width / $progress.clientWidth) * art.duration
|
|
||||||
const time = secondToTime(second)
|
|
||||||
const percentage = clamp(width / $progress.clientWidth, 0, 1)
|
|
||||||
return { second, time, width, percentage }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setCurrentTime(art, event) {
|
|
||||||
if (art.isRotate) {
|
|
||||||
const percentage = (event.touches[0].clientY - art.top) / art.height
|
|
||||||
const second = percentage * art.duration
|
|
||||||
art.emit('setBar', 'played', percentage, event)
|
|
||||||
art.seek = second
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const { second, percentage } = getPosFromEvent(art, event)
|
|
||||||
art.emit('setBar', 'played', percentage, event)
|
|
||||||
art.seek = second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function progress(options) {
|
|
||||||
return (art) => {
|
|
||||||
const { icons, option, proxy } = art
|
|
||||||
const { $player, $progress } = art.template
|
|
||||||
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
html: `
|
|
||||||
<div class="art-control-progress-inner">
|
|
||||||
<div class="art-progress-hover"></div>
|
|
||||||
<div class="art-progress-loaded"></div>
|
|
||||||
<div class="art-progress-played"></div>
|
|
||||||
<div class="art-progress-highlight"></div>
|
|
||||||
<div class="art-progress-indicator"></div>
|
|
||||||
<div class="art-progress-tip">00:00</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
mounted: ($control) => {
|
|
||||||
let tipTimer = null
|
|
||||||
let isDragging = false
|
|
||||||
|
|
||||||
const $hover = query('.art-progress-hover', $control)
|
|
||||||
const $loaded = query('.art-progress-loaded', $control)
|
|
||||||
const $played = query('.art-progress-played', $control)
|
|
||||||
const $highlight = query('.art-progress-highlight', $control)
|
|
||||||
const $indicator = query('.art-progress-indicator', $control)
|
|
||||||
const $tip = query('.art-progress-tip', $control)
|
|
||||||
|
|
||||||
if (icons.indicator) {
|
|
||||||
append($indicator, icons.indicator)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showHighlight(event) {
|
|
||||||
const { width } = getPosFromEvent(art, event)
|
|
||||||
const { text } = event.target.dataset
|
|
||||||
$tip.textContent = text
|
|
||||||
const tipWidth = $tip.clientWidth
|
|
||||||
if (width <= tipWidth / 2) {
|
|
||||||
setStyle($tip, 'left', 0)
|
|
||||||
}
|
|
||||||
else if (width > $control.clientWidth - tipWidth / 2) {
|
|
||||||
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTime(event, touch) {
|
|
||||||
const { width, time } = touch || getPosFromEvent(art, event)
|
|
||||||
$tip.textContent = time || '00:00'
|
|
||||||
const tipWidth = $tip.clientWidth
|
|
||||||
if (width <= tipWidth / 2) {
|
|
||||||
setStyle($tip, 'left', 0)
|
|
||||||
}
|
|
||||||
else if (width > $control.clientWidth - tipWidth / 2) {
|
|
||||||
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHighlight() {
|
|
||||||
$highlight.textContent = ''
|
|
||||||
for (let index = 0; index < option.highlight.length; index++) {
|
|
||||||
const item = option.highlight[index]
|
|
||||||
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
|
|
||||||
const html = `<span data-text="${item.text}" data-time="${item.time}" style="left: ${left}%"></span>`
|
|
||||||
append($highlight, html)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBar(type, percentage, event) {
|
|
||||||
const isMobileDragging = type === 'played' && event && isMobile
|
|
||||||
|
|
||||||
if (type === 'loaded') {
|
|
||||||
setStyle($loaded, 'width', `${percentage * 100}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'hover') {
|
|
||||||
setStyle($hover, 'width', `${percentage * 100}%`)
|
|
||||||
|
|
||||||
if (includeFromEvent(event, $highlight)) {
|
|
||||||
showHighlight(event)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
showTime(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (percentage === 0) {
|
|
||||||
removeClass($player, 'art-progress-hover')
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
addClass($player, 'art-progress-hover')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'played') {
|
|
||||||
setStyle($played, 'width', `${percentage * 100}%`)
|
|
||||||
setStyle($indicator, 'left', `${percentage * 100}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobileDragging) {
|
|
||||||
addClass($player, 'art-progress-hover')
|
|
||||||
const width = $control.clientWidth * percentage
|
|
||||||
const time = secondToTime(percentage * art.duration)
|
|
||||||
showTime(event, { width, time })
|
|
||||||
clearTimeout(tipTimer)
|
|
||||||
tipTimer = setTimeout(() => {
|
|
||||||
removeClass($player, 'art-progress-hover')
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
art.on('setBar', setBar)
|
|
||||||
art.on('video:loadedmetadata', updateHighlight)
|
|
||||||
|
|
||||||
if (art.constructor.USE_RAF) {
|
|
||||||
art.on('raf', () => {
|
|
||||||
art.emit('setBar', 'played', art.played)
|
|
||||||
art.emit('setBar', 'loaded', art.loaded)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
art.on('video:timeupdate', () => {
|
|
||||||
art.emit('setBar', 'played', art.played)
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('video:progress', () => {
|
|
||||||
art.emit('setBar', 'loaded', art.loaded)
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('video:ended', () => {
|
|
||||||
art.emit('setBar', 'played', 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
art.emit('setBar', 'loaded', art.loaded || 0)
|
|
||||||
|
|
||||||
if (!isMobile) {
|
|
||||||
proxy($progress, 'click', (event) => {
|
|
||||||
if (event.target !== $indicator) {
|
|
||||||
setCurrentTime(art, event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
proxy($progress, 'mousemove', (event) => {
|
|
||||||
const { percentage } = getPosFromEvent(art, event)
|
|
||||||
art.emit('setBar', 'hover', percentage, event)
|
|
||||||
})
|
|
||||||
|
|
||||||
proxy($progress, 'mouseleave', (event) => {
|
|
||||||
art.emit('setBar', 'hover', 0, event)
|
|
||||||
})
|
|
||||||
|
|
||||||
proxy($progress, 'mousedown', (event) => {
|
|
||||||
isDragging = event.button === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('document:mousemove', (event) => {
|
|
||||||
if (isDragging) {
|
|
||||||
const { second, percentage } = getPosFromEvent(art, event)
|
|
||||||
art.emit('setBar', 'played', percentage, event)
|
|
||||||
art.seek = second
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('document:mouseup', () => {
|
|
||||||
if (isDragging) {
|
|
||||||
isDragging = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { installProgressInteractions } from './progress/interactions'
|
||||||
|
import { mountProgressView } from './progress/view'
|
||||||
|
|
||||||
|
export { getPosFromEvent, setCurrentTime } from './progress/position'
|
||||||
|
|
||||||
|
export default function progress(options: ControlOption): ControlFactory {
|
||||||
|
return art => ({
|
||||||
|
...options,
|
||||||
|
html: `
|
||||||
|
<div class="art-control-progress-inner">
|
||||||
|
<div class="art-progress-hover"></div>
|
||||||
|
<div class="art-progress-loaded"></div>
|
||||||
|
<div class="art-progress-played"></div>
|
||||||
|
<div class="art-progress-highlight"></div>
|
||||||
|
<div class="art-progress-indicator"></div>
|
||||||
|
<div class="art-progress-tip">00:00</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
mounted: ($control) => {
|
||||||
|
mountProgressView(art, $control)
|
||||||
|
installProgressInteractions(art, $control)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ControlHost } from '../types'
|
||||||
|
import { isMobile } from '../../utils'
|
||||||
|
import { controlEvents } from '../resources'
|
||||||
|
import { getPosFromEvent, setCurrentTime } from './position'
|
||||||
|
|
||||||
|
export function installProgressInteractions(art: ControlHost, $control: HTMLDivElement): void {
|
||||||
|
const { $progress } = art.template
|
||||||
|
const $indicator = $control.querySelector('.art-progress-indicator')
|
||||||
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
if (!isMobile) {
|
||||||
|
let isDragging = false
|
||||||
|
proxy($progress, 'click', (event) => {
|
||||||
|
if (event.target !== $indicator) {
|
||||||
|
setCurrentTime(art, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy($progress, 'mousemove', (event) => {
|
||||||
|
const { percentage } = getPosFromEvent(art, event)
|
||||||
|
art.emit('setBar', 'hover', percentage, event)
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy($progress, 'mouseleave', (event) => {
|
||||||
|
art.emit('setBar', 'hover', 0, event)
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy($progress, 'mousedown', (event) => {
|
||||||
|
isDragging = event.button === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
on('document:mousemove', (event) => {
|
||||||
|
if (isDragging) {
|
||||||
|
const { second, percentage } = getPosFromEvent(art, event)
|
||||||
|
art.emit('setBar', 'played', percentage, event)
|
||||||
|
art.seek = second
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
on('document:mouseup', () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ControlHost } from '../types'
|
||||||
|
import { clamp, getRect, isMobile, secondToTime } from '../../utils'
|
||||||
|
|
||||||
|
export type ProgressEvent = MouseEvent | TouchEvent
|
||||||
|
type ProgressPositionHost = Pick<ControlHost, 'template' | 'duration' | 'isRotate' | 'top' | 'height' | 'emit' | 'seek'>
|
||||||
|
|
||||||
|
export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
|
||||||
|
const { $progress } = art.template
|
||||||
|
const { left } = getRect($progress)
|
||||||
|
const eventLeft = isMobile ? (event as TouchEvent).touches[0]!.clientX : (event as MouseEvent).clientX
|
||||||
|
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
|
||||||
|
const second = (width / $progress.clientWidth) * art.duration
|
||||||
|
const time = secondToTime(second)
|
||||||
|
const percentage = clamp(width / $progress.clientWidth, 0, 1)
|
||||||
|
return { second, time, width, percentage }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
|
||||||
|
if (art.isRotate) {
|
||||||
|
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
|
||||||
|
const second = percentage * art.duration
|
||||||
|
art.emit('setBar', 'played', percentage, event)
|
||||||
|
art.seek = second
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const { second, percentage } = getPosFromEvent(art, event)
|
||||||
|
art.emit('setBar', 'played', percentage, event)
|
||||||
|
art.seek = second
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ControlHost } from '../types'
|
||||||
|
import type { ProgressEvent } from './position'
|
||||||
|
import { queryElement } from '../../component/dom'
|
||||||
|
import { entryScope } from '../../component/resources'
|
||||||
|
import { timeout } from '../../lifecycle/resources'
|
||||||
|
import { addClass, append, clamp, includeFromEvent, isMobile, removeClass, secondToTime, setStyle } from '../../utils'
|
||||||
|
import { controlEvents } from '../resources'
|
||||||
|
import { getPosFromEvent } from './position'
|
||||||
|
|
||||||
|
export function mountProgressView(art: ControlHost, $control: HTMLDivElement): void {
|
||||||
|
const { icons, option } = art
|
||||||
|
const { $player } = art.template
|
||||||
|
const scope = entryScope($control)
|
||||||
|
const { on } = controlEvents(art, $control)
|
||||||
|
let cancelTip = () => {}
|
||||||
|
|
||||||
|
const $hover = queryElement('.art-progress-hover', $control)
|
||||||
|
const $loaded = queryElement('.art-progress-loaded', $control)
|
||||||
|
const $played = queryElement('.art-progress-played', $control)
|
||||||
|
const $highlight = queryElement('.art-progress-highlight', $control)
|
||||||
|
const $indicator = queryElement('.art-progress-indicator', $control)
|
||||||
|
const $tip = queryElement('.art-progress-tip', $control)
|
||||||
|
|
||||||
|
if (icons.indicator) {
|
||||||
|
append($indicator, icons.indicator)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHighlight(event: ProgressEvent) {
|
||||||
|
const { width } = getPosFromEvent(art, event)
|
||||||
|
const text = event.target instanceof HTMLElement ? event.target.dataset.text : undefined
|
||||||
|
$tip.textContent = text ?? ''
|
||||||
|
const tipWidth = $tip.clientWidth
|
||||||
|
if (width <= tipWidth / 2) {
|
||||||
|
setStyle($tip, 'left', 0)
|
||||||
|
}
|
||||||
|
else if (width > $control.clientWidth - tipWidth / 2) {
|
||||||
|
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTime(event: ProgressEvent, touch?: { width: number, time: string }) {
|
||||||
|
const { width, time } = touch || getPosFromEvent(art, event)
|
||||||
|
$tip.textContent = time || '00:00'
|
||||||
|
const tipWidth = $tip.clientWidth
|
||||||
|
if (width <= tipWidth / 2) {
|
||||||
|
setStyle($tip, 'left', 0)
|
||||||
|
}
|
||||||
|
else if (width > $control.clientWidth - tipWidth / 2) {
|
||||||
|
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHighlight() {
|
||||||
|
$highlight.textContent = ''
|
||||||
|
for (let index = 0; index < option.highlight.length; index++) {
|
||||||
|
const item = option.highlight[index]!
|
||||||
|
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
|
||||||
|
const marker = document.createElement('span')
|
||||||
|
marker.dataset.text = item.text
|
||||||
|
marker.dataset.time = String(item.time)
|
||||||
|
marker.style.left = `${left}%`
|
||||||
|
append($highlight, marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBar(type: string, percentage: number, event?: ProgressEvent) {
|
||||||
|
const isMobileDragging = type === 'played' && event && isMobile
|
||||||
|
|
||||||
|
if (type === 'loaded') {
|
||||||
|
setStyle($loaded, 'width', `${percentage * 100}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'hover') {
|
||||||
|
setStyle($hover, 'width', `${percentage * 100}%`)
|
||||||
|
|
||||||
|
if (includeFromEvent(event!, $highlight)) {
|
||||||
|
showHighlight(event!)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
showTime(event!)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percentage === 0) {
|
||||||
|
removeClass($player, 'art-progress-hover')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addClass($player, 'art-progress-hover')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'played') {
|
||||||
|
setStyle($played, 'width', `${percentage * 100}%`)
|
||||||
|
setStyle($indicator, 'left', `${percentage * 100}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobileDragging) {
|
||||||
|
addClass($player, 'art-progress-hover')
|
||||||
|
const width = $control.clientWidth * percentage
|
||||||
|
const time = secondToTime(percentage * art.duration)
|
||||||
|
showTime(event!, { width, time })
|
||||||
|
cancelTip()
|
||||||
|
cancelTip = timeout(scope, () => {
|
||||||
|
removeClass($player, 'art-progress-hover')
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on('setBar', setBar)
|
||||||
|
on('video:loadedmetadata', updateHighlight)
|
||||||
|
|
||||||
|
if (art.constructor.USE_RAF) {
|
||||||
|
on('raf', () => {
|
||||||
|
art.emit('setBar', 'played', art.played)
|
||||||
|
art.emit('setBar', 'loaded', art.loaded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
on('video:timeupdate', () => {
|
||||||
|
art.emit('setBar', 'played', art.played)
|
||||||
|
})
|
||||||
|
|
||||||
|
on('video:progress', () => {
|
||||||
|
art.emit('setBar', 'loaded', art.loaded)
|
||||||
|
})
|
||||||
|
|
||||||
|
on('video:ended', () => {
|
||||||
|
art.emit('setBar', 'played', 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
art.emit('setBar', 'loaded', art.loaded || 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { SubscriptionHost } from '../component/resources'
|
||||||
|
import type { ComponentHost } from '../component/types'
|
||||||
|
import type { UIEvents } from './types'
|
||||||
|
import { proxyEntry, subscribeEntry } from '../component/resources'
|
||||||
|
|
||||||
|
export function controlEvents(art: ComponentHost & SubscriptionHost<UIEvents>, element: HTMLElement) {
|
||||||
|
return {
|
||||||
|
on: <Name extends keyof UIEvents>(name: Name, callback: (...args: [...UIEvents[Name]]) => unknown): void => {
|
||||||
|
subscribeEntry<UIEvents, Name>(art, element, name, callback)
|
||||||
|
},
|
||||||
|
proxy: <Name extends keyof HTMLElementEventMap>(target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): (() => void) => proxyEntry(art, element, target, name, callback),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { append } from '../utils'
|
|
||||||
|
|
||||||
export default function screenshot(option) {
|
|
||||||
return art => ({
|
|
||||||
...option,
|
|
||||||
tooltip: art.i18n.get('Screenshot'),
|
|
||||||
mounted: ($control) => {
|
|
||||||
const { proxy, icons } = art
|
|
||||||
|
|
||||||
append($control, icons.screenshot)
|
|
||||||
proxy($control, 'click', () => {
|
|
||||||
art.screenshot()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
|
export default function screenshot(option: ControlOption): ControlFactory {
|
||||||
|
return art => ({
|
||||||
|
...option,
|
||||||
|
tooltip: art.i18n.get('Screenshot'),
|
||||||
|
mounted: ($control) => {
|
||||||
|
const { proxy } = controlEvents(art, $control)
|
||||||
|
const { icons } = art
|
||||||
|
|
||||||
|
appendElement($control, icons.screenshot)
|
||||||
|
proxy($control, 'click', () => {
|
||||||
|
art.screenshot()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { EntryOption, EventCleanup, SelectorItem } from '../component/types'
|
||||||
|
import type { ControlHost } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { entryScope } from '../component/resources'
|
||||||
|
import { trackSelection } from '../component/selection'
|
||||||
|
import { addClass, append, def, errorHandle, getComposedPath, inverseClass } from '../utils'
|
||||||
|
|
||||||
|
interface Binding {
|
||||||
|
option: EntryOption<ControlHost>
|
||||||
|
item: HTMLDivElement
|
||||||
|
value: HTMLDivElement
|
||||||
|
owner: HTMLDivElement
|
||||||
|
}
|
||||||
|
const bindings = new WeakMap<SelectorItem, Binding>()
|
||||||
|
|
||||||
|
function bind(item: SelectorItem, binding: Binding): void {
|
||||||
|
const previous = bindings.get(item)
|
||||||
|
errorHandle(!previous || entryScope(previous.owner).closed, 'Cannot share selector items between active controls')
|
||||||
|
if (!previous) {
|
||||||
|
def(item, '$control_option', { get: () => bindings.get(item)!.option.selector })
|
||||||
|
def(item, '$control_item', { get: () => bindings.get(item)!.item })
|
||||||
|
def(item, '$control_value', { get: () => bindings.get(item)!.value })
|
||||||
|
}
|
||||||
|
bindings.set(item, binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebIDL keeps legacy numeric/Element coercion and null-to-empty innerHTML behavior.
|
||||||
|
function setHTML(element: HTMLElement, value: unknown): void {
|
||||||
|
element.innerHTML = value as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkSelector(target?: SelectorItem): void {
|
||||||
|
if (!target)
|
||||||
|
return
|
||||||
|
setHTML(target.$control_value!, target.html)
|
||||||
|
for (let index = 0; index < target.$control_option!.length; index++) {
|
||||||
|
const item = target.$control_option![index]!
|
||||||
|
item.default = item === target
|
||||||
|
if (item.default)
|
||||||
|
inverseClass(item.$control_item, 'art-current')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSelector(art: ControlHost, check: (target?: SelectorItem) => void, option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
|
||||||
|
const { proxy } = art.events
|
||||||
|
const scope = entryScope($ref)
|
||||||
|
const selector = option.selector!
|
||||||
|
addClass($ref, 'art-control-selector')
|
||||||
|
const $value = document.createElement('div')
|
||||||
|
addClass($value, 'art-selector-value')
|
||||||
|
append($value, option.html)
|
||||||
|
$ref.textContent = ''
|
||||||
|
append($ref, $value)
|
||||||
|
const $list = appendElement($ref, '<div class="art-selector-list"></div>')
|
||||||
|
for (let index = 0; index < selector.length; index++) {
|
||||||
|
const item = selector[index]!
|
||||||
|
const $item = document.createElement('div')
|
||||||
|
addClass($item, 'art-selector-item')
|
||||||
|
if (item.default)
|
||||||
|
addClass($item, 'art-current')
|
||||||
|
$item.dataset.index = String(index)
|
||||||
|
$item.dataset.value = String(item.value)
|
||||||
|
setHTML($item, item.html)
|
||||||
|
append($list, $item)
|
||||||
|
bind(item, { option, item: $item, value: $value, owner: $ref })
|
||||||
|
}
|
||||||
|
let generation = 0
|
||||||
|
const event = proxy($list, 'click', async (event) => {
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
const path = getComposedPath(event)
|
||||||
|
const item = option.selector!.find(item => path.includes(item.$control_item))
|
||||||
|
if (!item)
|
||||||
|
return
|
||||||
|
const current = ++generation
|
||||||
|
const active = () => !scope.closed && current === generation
|
||||||
|
const release = scope.add(trackSelection(event, active))
|
||||||
|
try {
|
||||||
|
check(item)
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
if (option.onSelect) {
|
||||||
|
const value = await option.onSelect.call(art, item, item.$control_item!, event)
|
||||||
|
if (active())
|
||||||
|
setHTML($value, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('ArtPlayer selector failed:', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
events.push(event)
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { append, tooltip } from '../utils'
|
|
||||||
|
|
||||||
export default function setting(option) {
|
|
||||||
return art => ({
|
|
||||||
...option,
|
|
||||||
tooltip: art.i18n.get('Show Setting'),
|
|
||||||
mounted: ($control) => {
|
|
||||||
const { proxy, icons, i18n } = art
|
|
||||||
|
|
||||||
append($control, icons.setting)
|
|
||||||
|
|
||||||
proxy($control, 'click', () => {
|
|
||||||
art.setting.toggle()
|
|
||||||
art.setting.resize()
|
|
||||||
})
|
|
||||||
|
|
||||||
art.on('setting', (value) => {
|
|
||||||
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { tooltip } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
|
export default function setting(option: ControlOption): ControlFactory {
|
||||||
|
return art => ({
|
||||||
|
...option,
|
||||||
|
tooltip: art.i18n.get('Show Setting'),
|
||||||
|
mounted: ($control) => {
|
||||||
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
const { icons, i18n } = art
|
||||||
|
|
||||||
|
appendElement($control, icons.setting)
|
||||||
|
|
||||||
|
proxy($control, 'click', () => {
|
||||||
|
art.setting.toggle()
|
||||||
|
art.setting.resize()
|
||||||
|
})
|
||||||
|
|
||||||
|
on('setting', (value) => {
|
||||||
|
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
import { isMobile, secondToTime } from '../utils'
|
import { isMobile, secondToTime } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
export default function time(option) {
|
export default function time(option: ControlOption): ControlFactory {
|
||||||
return art => ({
|
return art => ({
|
||||||
...option,
|
...option,
|
||||||
style: isMobile
|
style: isMobile
|
||||||
@@ -13,6 +15,7 @@ export default function time(option) {
|
|||||||
padding: '0 10px',
|
padding: '0 10px',
|
||||||
},
|
},
|
||||||
mounted: ($control) => {
|
mounted: ($control) => {
|
||||||
|
const { on } = controlEvents(art, $control)
|
||||||
function getTime() {
|
function getTime() {
|
||||||
const newTime = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
|
const newTime = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
|
||||||
if (newTime !== $control.textContent) {
|
if (newTime !== $control.textContent) {
|
||||||
@@ -22,9 +25,9 @@ export default function time(option) {
|
|||||||
|
|
||||||
getTime()
|
getTime()
|
||||||
|
|
||||||
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress']
|
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress'] as const
|
||||||
for (let index = 0; index < events.length; index++) {
|
for (let index = 0; index < events.length; index++) {
|
||||||
art.on(events[index], getTime)
|
on(events[index]!, getTime)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { SubscriptionHost } from '../component/resources'
|
||||||
|
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
|
||||||
|
|
||||||
|
export interface UIEvents {
|
||||||
|
'mousemove': [MouseEvent]
|
||||||
|
'click': [MouseEvent]
|
||||||
|
'document:mousemove': [MouseEvent]
|
||||||
|
'document:mouseup': [MouseEvent]
|
||||||
|
'video:timeupdate': [Event]
|
||||||
|
'video:loadedmetadata': [Event]
|
||||||
|
'video:progress': [Event]
|
||||||
|
'video:ended': [Event]
|
||||||
|
'video:playing': [Event]
|
||||||
|
'video:pause': [Event]
|
||||||
|
'video:volumechange': [Event]
|
||||||
|
'video:ratechange': [Event]
|
||||||
|
'setBar': [type: string, percentage: number, event?: MouseEvent | TouchEvent]
|
||||||
|
'raf': []
|
||||||
|
'control': [boolean]
|
||||||
|
'fullscreen': [boolean]
|
||||||
|
'fullscreenWeb': [boolean]
|
||||||
|
'pip': [boolean]
|
||||||
|
'setting': [boolean]
|
||||||
|
'aspectRatio': [string]
|
||||||
|
'flip': [string]
|
||||||
|
'blur': []
|
||||||
|
'resize': []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityItem { html: string, url: string, default?: boolean }
|
||||||
|
|
||||||
|
type IconName = 'play' | 'pause' | 'volume' | 'volumeClose' | 'fullscreenOn' | 'fullscreenOff' | 'fullscreenWebOn' | 'fullscreenWebOff' | 'pip' | 'setting' | 'screenshot' | 'airplay'
|
||||||
|
|
||||||
|
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents> {
|
||||||
|
template: {
|
||||||
|
$player: HTMLDivElement
|
||||||
|
$bottom: HTMLDivElement
|
||||||
|
$controls: HTMLDivElement
|
||||||
|
$progress: HTMLDivElement
|
||||||
|
$controlsLeft: HTMLDivElement
|
||||||
|
$controlsRight: HTMLDivElement
|
||||||
|
}
|
||||||
|
option: {
|
||||||
|
isLive: boolean
|
||||||
|
quality: QualityItem[]
|
||||||
|
screenshot: boolean
|
||||||
|
setting: boolean
|
||||||
|
pip: boolean
|
||||||
|
airplay: boolean
|
||||||
|
fullscreenWeb: boolean
|
||||||
|
fullscreen: boolean
|
||||||
|
controls: EntryInput<ControlHost>[]
|
||||||
|
highlight: { time: number, text: string }[]
|
||||||
|
}
|
||||||
|
icons: Record<IconName, HTMLElement> & { indicator?: HTMLElement }
|
||||||
|
i18n: { get: (key: string) => string }
|
||||||
|
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean }
|
||||||
|
setting: { show: boolean, toggle: () => void, resize: () => void }
|
||||||
|
isInput: boolean
|
||||||
|
isRotate: boolean
|
||||||
|
playing: boolean
|
||||||
|
muted: boolean
|
||||||
|
volume: number
|
||||||
|
currentTime: number
|
||||||
|
duration: number
|
||||||
|
played: number
|
||||||
|
loaded: number
|
||||||
|
top: number
|
||||||
|
height: number
|
||||||
|
fullscreen: boolean
|
||||||
|
fullscreenWeb: boolean
|
||||||
|
pip: boolean
|
||||||
|
set seek(second: number)
|
||||||
|
set quality(value: QualityItem[])
|
||||||
|
play: () => unknown
|
||||||
|
pause: () => unknown
|
||||||
|
screenshot: () => unknown
|
||||||
|
airplay: () => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlOption = EntryOption<ControlHost>
|
||||||
|
export type ControlFactory = (art: ControlHost) => ControlOption
|
||||||
+22
-18
@@ -1,22 +1,26 @@
|
|||||||
import { append, getRect, isMobile, setStyle } from '../utils'
|
import type { ControlFactory, ControlOption } from './types'
|
||||||
|
import { appendElement } from '../component/dom'
|
||||||
|
import { getRect, isMobile, setStyle } from '../utils'
|
||||||
|
import { controlEvents } from './resources'
|
||||||
|
|
||||||
export default function volume(option) {
|
export default function volume(option: ControlOption): ControlFactory {
|
||||||
return art => ({
|
return art => ({
|
||||||
...option,
|
...option,
|
||||||
mounted: ($control) => {
|
mounted: ($control) => {
|
||||||
const { proxy, icons } = art
|
const { on, proxy } = controlEvents(art, $control)
|
||||||
|
const { icons } = art
|
||||||
|
|
||||||
const $volume = append($control, icons.volume)
|
const $volume = appendElement($control, icons.volume)
|
||||||
const $close = append($control, icons.volumeClose)
|
const $close = appendElement($control, icons.volumeClose)
|
||||||
const $panel = append($control, '<div class="art-volume-panel"></div>')
|
const $panel = appendElement($control, '<div class="art-volume-panel"></div>')
|
||||||
const $inner = append($panel, '<div class="art-volume-inner"></div>')
|
const $inner = appendElement($panel, '<div class="art-volume-inner"></div>')
|
||||||
const $value = append($inner, `<div class="art-volume-val"></div>`)
|
const $value = appendElement($inner, `<div class="art-volume-val"></div>`)
|
||||||
const $slider = append($inner, `<div class="art-volume-slider"></div>`)
|
const $slider = appendElement($inner, `<div class="art-volume-slider"></div>`)
|
||||||
const $handle = append($slider, `<div class="art-volume-handle"></div>`)
|
const $handle = appendElement($slider, `<div class="art-volume-handle"></div>`)
|
||||||
const $loaded = append($handle, `<div class="art-volume-loaded"></div>`)
|
const $loaded = appendElement($handle, `<div class="art-volume-loaded"></div>`)
|
||||||
const $indicator = append($slider, `<div class="art-volume-indicator"></div>`)
|
const $indicator = appendElement($slider, `<div class="art-volume-indicator"></div>`)
|
||||||
|
|
||||||
function getVolumeFromEvent(event) {
|
function getVolumeFromEvent(event: MouseEvent) {
|
||||||
const { top, height } = getRect($slider)
|
const { top, height } = getRect($slider)
|
||||||
return 1 - (event.clientY - top) / height
|
return 1 - (event.clientY - top) / height
|
||||||
}
|
}
|
||||||
@@ -27,7 +31,7 @@ export default function volume(option) {
|
|||||||
setStyle($close, 'display', 'flex')
|
setStyle($close, 'display', 'flex')
|
||||||
setStyle($indicator, 'top', '100%')
|
setStyle($indicator, 'top', '100%')
|
||||||
setStyle($loaded, 'top', '100%')
|
setStyle($loaded, 'top', '100%')
|
||||||
$value.textContent = 0
|
$value.textContent = '0'
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const percentage = art.volume * 100
|
const percentage = art.volume * 100
|
||||||
@@ -35,12 +39,12 @@ export default function volume(option) {
|
|||||||
setStyle($close, 'display', 'none')
|
setStyle($close, 'display', 'none')
|
||||||
setStyle($indicator, 'top', `${100 - percentage}%`)
|
setStyle($indicator, 'top', `${100 - percentage}%`)
|
||||||
setStyle($loaded, 'top', `${100 - percentage}%`)
|
setStyle($loaded, 'top', `${100 - percentage}%`)
|
||||||
$value.textContent = Math.floor(percentage)
|
$value.textContent = String(Math.floor(percentage))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update()
|
update()
|
||||||
art.on('video:volumechange', update)
|
on('video:volumechange', update)
|
||||||
|
|
||||||
proxy($volume, 'click', () => {
|
proxy($volume, 'click', () => {
|
||||||
art.muted = true
|
art.muted = true
|
||||||
@@ -61,14 +65,14 @@ export default function volume(option) {
|
|||||||
art.volume = getVolumeFromEvent(event)
|
art.volume = getVolumeFromEvent(event)
|
||||||
})
|
})
|
||||||
|
|
||||||
art.on('document:mousemove', (event) => {
|
on('document:mousemove', (event) => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
art.muted = false
|
art.muted = false
|
||||||
art.volume = getVolumeFromEvent(event)
|
art.volume = getVolumeFromEvent(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
art.on('document:mouseup', () => {
|
on('document:mouseup', () => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
isDragging = false
|
isDragging = false
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Component from './utils/component'
|
|
||||||
|
|
||||||
export default class Layer extends Component {
|
|
||||||
constructor(art) {
|
|
||||||
super(art)
|
|
||||||
|
|
||||||
const {
|
|
||||||
option,
|
|
||||||
template: { $layer },
|
|
||||||
} = art
|
|
||||||
|
|
||||||
this.name = 'layer'
|
|
||||||
this.$parent = $layer
|
|
||||||
|
|
||||||
for (let index = 0; index < option.layers.length; index++) {
|
|
||||||
this.add(option.layers[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ComponentHost, EntryInput } from './component/types'
|
||||||
|
import Component from './utils/component'
|
||||||
|
|
||||||
|
export interface LayerHost extends ComponentHost {
|
||||||
|
option: { layers: EntryInput<LayerHost>[] }
|
||||||
|
template: { $player: HTMLElement, $layer: HTMLElement }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Layer extends Component<LayerHost> {
|
||||||
|
constructor(art: LayerHost) {
|
||||||
|
super(art)
|
||||||
|
const { option, template: { $layer } } = art
|
||||||
|
this.name = 'layer'
|
||||||
|
this.$parent = $layer
|
||||||
|
for (let index = 0; index < option.layers.length; index++)
|
||||||
|
this.add(option.layers[index]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { def } from '../utils'
|
|
||||||
|
|
||||||
export default function qualityMix(art) {
|
|
||||||
def(art, 'quality', {
|
|
||||||
set(quality) {
|
|
||||||
const { controls, notice, i18n } = art
|
|
||||||
const qualityDefault = quality.find(item => item.default) || quality[0]
|
|
||||||
controls.update({
|
|
||||||
name: 'quality',
|
|
||||||
position: 'right',
|
|
||||||
index: 10,
|
|
||||||
style: {
|
|
||||||
marginRight: '10px',
|
|
||||||
},
|
|
||||||
html: qualityDefault?.html || '',
|
|
||||||
selector: quality,
|
|
||||||
async onSelect(item) {
|
|
||||||
await art.switchQuality(item.url)
|
|
||||||
notice.show = `${i18n.get('Switch Video')}: ${item.html}`
|
|
||||||
return item.html
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ComponentHost, EntryOption } from '../component/types'
|
||||||
|
import type { QualityItem } from '../control/types'
|
||||||
|
import { captureSelection } from '../component/selection'
|
||||||
|
import { isClosing } from '../lifecycle/instance'
|
||||||
|
import { def } from '../utils'
|
||||||
|
|
||||||
|
export interface QualityHost extends ComponentHost {
|
||||||
|
controls: { update: (option: EntryOption<QualityHost>) => unknown }
|
||||||
|
notice: { set show(message: string) }
|
||||||
|
i18n: { get: (key: string) => string }
|
||||||
|
switchQuality: (url: string) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function qualityMix(art: QualityHost): void {
|
||||||
|
def(art, 'quality', {
|
||||||
|
set(quality: QualityItem[]) {
|
||||||
|
const { controls, notice, i18n } = art
|
||||||
|
const qualityDefault = quality.find(item => item.default) || quality[0]
|
||||||
|
controls.update({
|
||||||
|
name: 'quality',
|
||||||
|
position: 'right',
|
||||||
|
index: 10,
|
||||||
|
style: { marginRight: '10px' },
|
||||||
|
html: qualityDefault?.html || '',
|
||||||
|
selector: quality,
|
||||||
|
async onSelect(item, _element, event) {
|
||||||
|
const active = captureSelection(event)
|
||||||
|
// This callback is installed with the quality array above.
|
||||||
|
await art.switchQuality((item as QualityItem).url)
|
||||||
|
if (!isClosing(art) && active()) {
|
||||||
|
const message = `${i18n.get('Switch Video')}: ${item.html}`
|
||||||
|
if (!isClosing(art) && active())
|
||||||
|
notice.show = message
|
||||||
|
}
|
||||||
|
return item.html
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,12 +6,17 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: var(--art-control-height);
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: auto;
|
||||||
|
min-height: var(--art-control-height);
|
||||||
|
|
||||||
.art-controls-left,
|
.art-controls-left,
|
||||||
.art-controls-right {
|
.art-controls-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
flex-wrap: wrap;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: var(--art-control-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-controls-center {
|
.art-controls-center {
|
||||||
@@ -23,11 +28,13 @@
|
|||||||
|
|
||||||
.art-controls-right {
|
.art-controls-right {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-control {
|
.art-control {
|
||||||
.art-center();
|
.art-center();
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
max-width: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
opacity: var(--art-control-opacity);
|
opacity: var(--art-control-opacity);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
left: var(--art-padding);
|
left: var(--art-padding);
|
||||||
bottom: calc(var(--art-control-height) + var(--art-bottom-gap) + 10px);
|
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + 10px);
|
||||||
background-color: var(--art-widget-background);
|
background-color: var(--art-widget-background);
|
||||||
|
|
||||||
.art-auto-playback-close {
|
.art-auto-playback-close {
|
||||||
@@ -55,7 +55,12 @@
|
|||||||
|
|
||||||
.art-controls,
|
.art-controls,
|
||||||
.art-progress {
|
.art-progress {
|
||||||
transform: translateY(calc(var(--art-control-height) + var(--art-bottom-gap) + var(--art-progress-height) / 4));
|
transform: translateY(
|
||||||
|
calc(
|
||||||
|
var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + var(--art-progress-height) /
|
||||||
|
4
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-progress-indicator {
|
.art-progress-indicator {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
border-radius: var(--art-border-radius);
|
border-radius: var(--art-border-radius);
|
||||||
max-height: var(--art-settings-max-height);
|
max-height: var(--art-settings-max-height);
|
||||||
right: var(--art-padding);
|
right: var(--art-padding);
|
||||||
bottom: var(--art-control-height);
|
bottom: var(--art-controls-height, var(--art-control-height));
|
||||||
transition: all var(--art-transition-duration) ease;
|
transition: all var(--art-transition-duration) ease;
|
||||||
background-color: var(--art-widget-background);
|
background-color: var(--art-widget-background);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
&.art-control-show {
|
&.art-control-show {
|
||||||
.art-subtitle {
|
.art-subtitle {
|
||||||
bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));
|
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-subtitle-bottom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import validator from 'option-validator'
|
|
||||||
import { ComponentOption } from '../scheme'
|
|
||||||
import { addClass, append, createElement, hasClass, remove, removeClass, setStyles, tooltip } from './dom'
|
|
||||||
import { errorHandle } from './error'
|
|
||||||
|
|
||||||
export default class Component {
|
|
||||||
constructor(art) {
|
|
||||||
this.id = 0
|
|
||||||
this.art = art
|
|
||||||
this.cache = new Map()
|
|
||||||
this.add = this.add.bind(this)
|
|
||||||
this.remove = this.remove.bind(this)
|
|
||||||
this.update = this.update.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
get show() {
|
|
||||||
return hasClass(this.art.template.$player, `art-${this.name}-show`)
|
|
||||||
}
|
|
||||||
|
|
||||||
set show(value) {
|
|
||||||
const { $player } = this.art.template
|
|
||||||
const className = `art-${this.name}-show`
|
|
||||||
if (value) {
|
|
||||||
addClass($player, className)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
removeClass($player, className)
|
|
||||||
}
|
|
||||||
this.art.emit(this.name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
this.show = !this.show
|
|
||||||
}
|
|
||||||
|
|
||||||
add(getOption) {
|
|
||||||
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
|
|
||||||
option.html = option.html || ''
|
|
||||||
validator(option, ComponentOption)
|
|
||||||
if (!this.$parent || !this.name || option.disable)
|
|
||||||
return
|
|
||||||
const name = option.name || `${this.name}${this.id}`
|
|
||||||
errorHandle(!this.cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
|
|
||||||
|
|
||||||
this.id += 1
|
|
||||||
const $ref = createElement('div')
|
|
||||||
addClass($ref, `art-${this.name}`)
|
|
||||||
addClass($ref, `art-${this.name}-${name}`)
|
|
||||||
|
|
||||||
const childs = Array.from(this.$parent.children)
|
|
||||||
$ref.dataset.index = option.index || this.id
|
|
||||||
const nextChild = childs.find(item => Number(item.dataset.index) >= Number($ref.dataset.index))
|
|
||||||
if (nextChild) {
|
|
||||||
nextChild.insertAdjacentElement('beforebegin', $ref)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
append(this.$parent, $ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.html) {
|
|
||||||
append($ref, option.html)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.style) {
|
|
||||||
setStyles($ref, option.style)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.tooltip) {
|
|
||||||
tooltip($ref, option.tooltip)
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = []
|
|
||||||
if (option.click) {
|
|
||||||
const destroyEvent = this.art.events.proxy($ref, 'click', (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
option.click.call(this.art, this, event)
|
|
||||||
})
|
|
||||||
events.push(destroyEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.selector && ['left', 'right'].includes(option.position)) {
|
|
||||||
this.selector(option, $ref, events)
|
|
||||||
}
|
|
||||||
|
|
||||||
this[name] = $ref
|
|
||||||
this.cache.set(name, { $ref, events, option })
|
|
||||||
|
|
||||||
if (option.mounted) {
|
|
||||||
option.mounted.call(this.art, $ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ref
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(name) {
|
|
||||||
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
|
|
||||||
const item = this.cache.get(name)
|
|
||||||
|
|
||||||
if (item.option.beforeUnmount) {
|
|
||||||
item.option.beforeUnmount.call(this.art, item.$ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const event of item.events) {
|
|
||||||
this.art.events.remove(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.delete(name)
|
|
||||||
delete this[name]
|
|
||||||
remove(item.$ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(option) {
|
|
||||||
if (this.cache.has(option.name)) {
|
|
||||||
const item = this.cache.get(option.name)
|
|
||||||
option = Object.assign(item.option, option)
|
|
||||||
this.remove(option.name)
|
|
||||||
}
|
|
||||||
return this.add(option)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { ComponentHost, Entry, EntryInput, EntryOption, EventCleanup } from '../component/types'
|
||||||
|
import validator from 'option-validator'
|
||||||
|
import { renderEntry } from '../component/dom'
|
||||||
|
import { ownEntry, releaseEntry } from '../component/resources'
|
||||||
|
import { isClosing } from '../lifecycle/instance'
|
||||||
|
import { ComponentOption } from '../scheme'
|
||||||
|
import { addClass, hasClass, removeClass } from './dom'
|
||||||
|
import { errorHandle } from './error'
|
||||||
|
|
||||||
|
const removing = new WeakSet<object>()
|
||||||
|
|
||||||
|
export default class Component<Host extends ComponentHost = ComponentHost> {
|
||||||
|
declare id: number
|
||||||
|
declare art: Host
|
||||||
|
declare cache: Map<string, Entry<Host>>
|
||||||
|
declare name?: string
|
||||||
|
declare $parent?: HTMLElement
|
||||||
|
selector?(option: EntryOption<Host>, element: HTMLDivElement, events: EventCleanup[]): void
|
||||||
|
|
||||||
|
constructor(art: Host) {
|
||||||
|
this.id = 0
|
||||||
|
this.art = art
|
||||||
|
this.cache = new Map()
|
||||||
|
this.add = this.add.bind(this)
|
||||||
|
this.remove = this.remove.bind(this)
|
||||||
|
this.update = this.update.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
get show(): boolean {
|
||||||
|
return hasClass(this.art.template.$player, `art-${this.name}-show`)
|
||||||
|
}
|
||||||
|
|
||||||
|
set show(value: boolean) {
|
||||||
|
const { $player } = this.art.template
|
||||||
|
const className = `art-${this.name}-show`
|
||||||
|
if (value)
|
||||||
|
addClass($player, className)
|
||||||
|
else
|
||||||
|
removeClass($player, className)
|
||||||
|
this.art.emit(this.name!, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): void {
|
||||||
|
this.show = !this.show
|
||||||
|
}
|
||||||
|
|
||||||
|
add(getOption: EntryInput<Host>): HTMLDivElement | undefined {
|
||||||
|
if (isClosing(this.art))
|
||||||
|
return
|
||||||
|
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
|
||||||
|
if (isClosing(this.art))
|
||||||
|
return
|
||||||
|
option.html = option.html || ''
|
||||||
|
validator(option, ComponentOption)
|
||||||
|
if (!this.$parent || !this.name || option.disable)
|
||||||
|
return
|
||||||
|
const name = option.name || `${this.name}${this.id}`
|
||||||
|
const cache = this.cache
|
||||||
|
errorHandle(!cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
|
||||||
|
this.id += 1
|
||||||
|
const $ref = document.createElement('div')
|
||||||
|
const scope = ownEntry(this.art, $ref)
|
||||||
|
const events: EventCleanup[] = []
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(this, name)
|
||||||
|
let aliased = false
|
||||||
|
scope.add(() => {
|
||||||
|
for (const event of events)
|
||||||
|
this.art.events.remove(event)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
renderEntry($ref, this.$parent, this.name, name, this.id, option)
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
if (option.click) {
|
||||||
|
const cleanup = this.art.events.proxy($ref, 'click', (event) => {
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
event.preventDefault()
|
||||||
|
option.click!.call(this.art, this, event)
|
||||||
|
})
|
||||||
|
events.push(cleanup)
|
||||||
|
}
|
||||||
|
if (option.selector && ['left', 'right'].includes(option.position!))
|
||||||
|
this.selector!(option, $ref, events)
|
||||||
|
if (scope.closed)
|
||||||
|
return
|
||||||
|
assignAlias(this, name, $ref)
|
||||||
|
aliased = true
|
||||||
|
cache.set(name, { $ref, events, option })
|
||||||
|
if (option.mounted)
|
||||||
|
option.mounted.call(this.art, $ref)
|
||||||
|
return $ref
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
try {
|
||||||
|
releaseEntry($ref)
|
||||||
|
}
|
||||||
|
catch (cleanupError) {
|
||||||
|
console.warn('ArtPlayer component cleanup failed:', cleanupError)
|
||||||
|
}
|
||||||
|
if (cache.get(name)?.$ref === $ref)
|
||||||
|
cache.delete(name)
|
||||||
|
if (aliased && (this as unknown as Record<string, unknown>)[name] === $ref) {
|
||||||
|
if (previous)
|
||||||
|
Object.defineProperty(this, name, previous)
|
||||||
|
else
|
||||||
|
delete (this as unknown as Record<string, unknown>)[name]
|
||||||
|
}
|
||||||
|
$ref.remove()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(name: string): void {
|
||||||
|
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
|
||||||
|
const item = this.cache.get(name)!
|
||||||
|
if (removing.has(item))
|
||||||
|
return
|
||||||
|
removing.add(item)
|
||||||
|
try {
|
||||||
|
if (item.option.beforeUnmount)
|
||||||
|
item.option.beforeUnmount.call(this.art, item.$ref)
|
||||||
|
try {
|
||||||
|
releaseEntry(item.$ref)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (this.cache.get(name) === item) {
|
||||||
|
this.cache.delete(name)
|
||||||
|
delete (this as unknown as Record<string, unknown>)[name]
|
||||||
|
}
|
||||||
|
item.$ref.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
removing.delete(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(option: EntryOption<Host>): ReturnType<this['add']> {
|
||||||
|
if (this.cache.has(option.name!)) {
|
||||||
|
const item = this.cache.get(option.name!)!
|
||||||
|
option = Object.assign(item.option, option)
|
||||||
|
this.remove(option.name!)
|
||||||
|
}
|
||||||
|
// add is polymorphic: Control intentionally returns undefined.
|
||||||
|
return this.add(option) as ReturnType<this['add']>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignAlias(owner: object, name: string, value: HTMLElement): void {
|
||||||
|
const aliases = owner as Record<string, unknown>
|
||||||
|
if (name === '__proto__')
|
||||||
|
Object.defineProperty(aliases, name, { value, enumerable: true, configurable: true, writable: true })
|
||||||
|
else
|
||||||
|
aliases[name] = value
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,41 @@
|
|||||||
|
# CORE-13 组件注册与控件
|
||||||
|
|
||||||
|
状态:完成;源码、严格类型、构建、安装包与本地三浏览器验收通过。
|
||||||
|
|
||||||
|
## 职责与兼容
|
||||||
|
|
||||||
|
Component、control、layer、contextmenu 和 qualityMix 迁移 TS。注册器、DOM、条目资源、选择生命周期、内置装配、布局观察、进度计算/视图/交互按实际职责拆分;简单控件保留独立工厂。共享 typed UIEvents 和最小宿主不引入第二套事件总线;主构造门面的整合仍交 CORE-20。包内 ARCHITECTURE.md 给出实际地图、修改入口与边界。
|
||||||
|
|
||||||
|
保持裸工厂调用、回调 this、原始 Event 和参数、索引排序、HTML 数字转换、更新时原配置对象合并、同步 mounted/beforeUnmount、属性别名及缓存条目形状。Component add/update 可返回 div;controls.add/update 继续返回 undefined。公开 d.ts 不在此轮收紧,BASE-TYPE-04 与只写 quality 属性的 BASE-TYPE-08 仍交 CORE-21。
|
||||||
|
|
||||||
|
条目资源在渲染前登记,remove/update 释放内置 DOM 与 Emitter 订阅、拖动和提示定时器;整实例销毁释放作用域,但不新增逐条 beforeUnmount 调用或清空历史 cache/alias。自定义 mounted 返回值仍忽略,用户自行创建的监听器/SDK 工作需自行 beforeUnmount 清理,没有凭空引入 disposer 协议。
|
||||||
|
|
||||||
|
## 缺陷与修正
|
||||||
|
|
||||||
|
- BASE-LIFE-09:旧控件移除后时间/音量/菜单仍响应事件,拖动仍可 seek。候选按条目释放并在 Emitter 快照内检查关闭状态,用户自己的订阅保留。
|
||||||
|
- BASE-LIFE-10:旧 mounted 抛错残留 DOM/cache/监听器,beforeUnmount 重入重复执行并抛错,已脱离 DOM 的条目移除也抛错。候选回滚失败条目且保留成功的重入替换,递归移除同条目幂等;beforeUnmount 自身抛错仍保留条目可重试并传播原异常。
|
||||||
|
- BASE-LIFE-11:旧 selector 背景点击及异步拒绝产生未处理异常,乱序和移除后结果仍覆盖标题。候选忽略无项点击,报告原始失败,只接受当前选择的完成;内置清晰度通知也绑定选择生命周期,包括通知文本准备期间重入。第三方 onSelect 自有异步副作用不会被自动取消。
|
||||||
|
- BASE-DOM-03:旧 selector 数组用于更新后因不可配置属性重复定义而抛错。候选保持 getter 标志,通过 WeakMap 重绑已关闭条目;同时共享到两个活动控件仍非法。
|
||||||
|
- BASE-DOM-04:旧 `__proto__` 名称别名会改变 registry 原型。候选为它定义普通 own 属性,仍可按名称读 DOM,不再改原型。
|
||||||
|
- BASE-DOM-05:旧 highlight.text 可通过引号注入 DOM 属性;候选使用 dataset 按文本写入。没有声称移除用户明确支持的 HTML 控件内容。
|
||||||
|
- BASE-DOM-02 部分:窄容器控件换行,所有按钮保持可见;新增 --art-controls-height 记录总高度,字幕/面板跟随,旧 --art-control-height 保持单行单位高度。CORE-14 仍需处理窄设置树/面板,因此此风险保持 open。
|
||||||
|
|
||||||
|
## 验证与过程
|
||||||
|
|
||||||
|
源码组件资源 8 项 Node 测试、根与核心 TS 检查通过;源码专项三浏览器 84 项通过,最终安装包 UMD 与 legacy 同套完整矩阵各 342 项通过。新增用例覆盖真实右键/点击、视频进度、旧新错误对照、回调/别名/返回值、乱序、清理和尺寸变化。ResizeObserver 不可用分支单独测试;没有将 UA 覆盖当实体设备证据。
|
||||||
|
|
||||||
|
首次窄容器用例增加实际 16:9 高度后暴露候选控件组仍 height:100% 的裁切:57 通过、3 失败。修正遗漏的子组 CSS,再验证 640/320/240px,保留原失败报告。之后观察器用例 81 通过、3 失败,原因是字幕仍在既有 CSS transition 中;改为有界轮询最终计算样式,未扩大超时或移除动画,随后 84 项通过。
|
||||||
|
|
||||||
|
本任务未新增依赖或修改锁文件;package.json 将组件资源测试加入既有 test:unit,确保 CI 执行。没有版本变更、推送或发布。原型名/生命周期/选择器修复为有旧新复现的缺陷修正,不静默改正常 API。回退需一起回退 TS 模块、样式、生成产物、测试和对应记录;不能仅回退组件基类而保留依赖其条目作用域的控件。
|
||||||
|
|
||||||
|
完整安装 UMD 首轮 341 通过、1 项 WebKit 切源位置失败。补充原生 seeking/seeked/loadedmetadata/canplay 轨迹后复现:初始 seek 已结束但实际落在接近 0 秒,切源保留的正是这个实际位置;旧测试仅等 seeking=false,没有证明初始 3 秒前提。诊断中精确等于 3 的临时断言也错误拒绝毫秒偏差,已改用原有 0.05 秒精度。曾尝试等待 buffered/seekable 均覆盖 3 秒,但本地 WebKit 实际播放后仍报告 buffered=[]、seekable=[[0,8]]、readyState=4,这个附加假设不成立,保留失败报告并撤回。最终用例通过真实播放/暂停手势启动媒体,再确认 seekable 目标并设置初始位置,在切换前后断言位置,保留范围与媒体轨迹;没有修改播放器逻辑、重复 seek、扩大超时或放宽原切换断言。专项每引擎重复 10 次用于确认该前提修正,再重跑完整矩阵。
|
||||||
|
|
||||||
|
## 最终交付证据
|
||||||
|
|
||||||
|
- yarn ci:check:177 项通过(148 单元、4 工程、25 基线),110 个生产 TS 文件严格检查;其中核心 105、chapter 5。
|
||||||
|
- yarn build artplayer 与 yarn build:i18n 成功;实际核心/chapter tarball 仓库外安装消费:27 项运行时通过、五组类型零诊断。
|
||||||
|
- 最终安装 UMD 和 legacy 各 342 项三浏览器通过,包含本任务 84 项;无跳过、重试、flaky 或未处理页面异常。旧缺陷测试显式收集并断言预期拒绝,没有把它们冒充候选成功行为。
|
||||||
|
- [验证记录](../baselines/components-validation.json) 校验全部核心源码与打包构建快照一致,三个格式产物与 tarball 及 docs/compiled 指纹一致,公开声明、包内架构文档、22 个语言文件和完整许可一致;记录实际引擎版本、逐项结果与早期失败原因。
|
||||||
|
- 关闭 BASE-LIFE-09/10/11 和 BASE-DOM-03/04/05;BASE-DOM-02 保持 open,窄设置面板交 CORE-14。没有远端 CI、实体设备或全生态发布验收声明。
|
||||||
|
|
||||||
|
本任务独立本地提交;当前总任务 214 项,49 完成、165 待办。下一项 CORE-14 设置树、渲染、选择与布局。
|
||||||
+17
-17
@@ -5,28 +5,28 @@
|
|||||||
## 现状证据
|
## 现状证据
|
||||||
|
|
||||||
- [入口](../packages/artplayer/src/index.js)顺序构造所有子系统;[player](../packages/artplayer/src/player/index.js)通过 mixin 将属性安装到 art。
|
- [入口](../packages/artplayer/src/index.js)顺序构造所有子系统;[player](../packages/artplayer/src/player/index.js)通过 mixin 将属性安装到 art。
|
||||||
- [Component](../packages/artplayer/src/utils/component.js)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.js)还有独立的树、渲染和更新语义。
|
- [Component](../packages/artplayer/src/utils/component.ts)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.js)还有独立的树、渲染和更新语义。
|
||||||
- [Events](../packages/artplayer/src/events/index.js)集中管理一部分 DOM 监听器,但定时器、异步请求和内部 Emitter 订阅仍由各模块管理。
|
- [Events](../packages/artplayer/src/events/index.js)集中管理一部分 DOM 监听器,但定时器、异步请求和内部 Emitter 订阅仍由各模块管理。
|
||||||
- [声明](../packages/artplayer/types/artplayer.d.ts)与源码独立维护;[build-ts](../scripts/build-ts.js)进行文本拼接。
|
- [声明](../packages/artplayer/types/artplayer.d.ts)与源码独立维护;[build-ts](../scripts/build-ts.js)进行文本拼接。
|
||||||
|
|
||||||
## 目标职责
|
## 目标职责
|
||||||
|
|
||||||
| 领域 | 现有文件范围 | 目标与迁移方式 |
|
| 领域 | 现有文件范围 | 目标与迁移方式 |
|
||||||
| --- | --- | --- |
|
| -------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
| 公共门面 | src/index.js、player/*Mix.js | 保留原签名和属性描述符;委托内部服务,不一次性换继承结构 |
|
| 公共门面 | src/index.js、player/*Mix.js | 保留原签名和属性描述符;委托内部服务,不一次性换继承结构 |
|
||||||
| 配置 | option、scheme、config、默认值 | 区分用户输入与归一化配置;保持默认值、合并语义和错误时机 |
|
| 配置 | option、scheme、config、默认值 | 区分用户输入与归一化配置;保持默认值、合并语义和错误时机 |
|
||||||
| 生命周期 | constructor、destroy、Events、各模块清理 | 实例资源作用域与操作资源作用域,清理恰好一次、可重入、失败后继续释放 |
|
| 生命周期 | constructor、destroy、Events、各模块清理 | 实例资源作用域与操作资源作用域,清理恰好一次、可重入、失败后继续释放 |
|
||||||
| 内部事件 | utils/emitter.js | typed event map、未知事件兼容扩展、原调用顺序和 ctx |
|
| 内部事件 | utils/emitter.js | typed event map、未知事件兼容扩展、原调用顺序和 ctx |
|
||||||
| 媒体能力 | template、proxy、player | 内部精确的能力接口与原生视频类型分离;公共 video 兼容层保留 |
|
| 媒体能力 | template、proxy、player | 内部精确的能力接口与原生视频类型分离;公共 video 兼容层保留 |
|
||||||
| 播放流程 | play/pause/toggle/seek/url/switch/eventInit | 媒体状态、操作身份、加载恢复与 UI 响应分离 |
|
| 播放流程 | play/pause/toggle/seek/url/switch/eventInit | 媒体状态、操作身份、加载恢复与 UI 响应分离 |
|
||||||
| UI 注册 | utils/component、control、layer、contextmenu | 保留 name/cache/update/remove;明确 DOM 和绑定的归属 |
|
| UI 注册 | utils/component、control、layer、contextmenu | 保留 name/cache/update/remove;明确 DOM 和绑定的归属 |
|
||||||
| 设置面板 | setting/index.js | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
|
| 设置面板 | setting/index.js | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
|
||||||
| 字幕 | subtitle.js、utils/subtitle.js | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
|
| 字幕 | subtitle.js、utils/subtitle.js | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
|
||||||
| 显示模式 | fullscreen/fullscreenWeb/pip/mini/auto* | 显式恢复位置和样式,保持互斥顺序、用户手势与移动端语义 |
|
| 显示模式 | fullscreen/fullscreenWeb/pip/mini/auto* | 显式恢复位置和样式,保持互斥顺序、用户手势与移动端语义 |
|
||||||
| 输入 | events、hotkey | 鼠标/触控/键盘/焦点归属清晰,跨 document 重新绑定和清理 |
|
| 输入 | events、hotkey | 鼠标/触控/键盘/焦点归属清晰,跨 document 重新绑定和清理 |
|
||||||
| 无障碍 | template、control、setting、显示模式 | 明确键盘导航、可访问名称、焦点进入退出和字幕能力,保留旧 DOM/CSS 与热键契约 |
|
| 无障碍 | template、control、setting、显示模式 | 明确键盘导航、可访问名称、焦点进入退出和字幕能力,保留旧 DOM/CSS 与热键契约 |
|
||||||
| 内置功能 | plugins/*、info/notice/loading/mask | 逐项登记定时器和订阅,不能因内部插件迁移改变默认启用条件 |
|
| 内置功能 | plugins/*、info/notice/loading/mask | 逐项登记定时器和订阅,不能因内部插件迁移改变默认启用条件 |
|
||||||
| 资源 | i18n、icons、style、libs | 保持子路径、图标键、CSS 类和样式注入;记录 vendored 依赖来源 |
|
| 资源 | i18n、icons、style、libs | 保持子路径、图标键、CSS 类和样式注入;记录 vendored 依赖来源 |
|
||||||
|
|
||||||
## 生命周期设计约束
|
## 生命周期设计约束
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
|
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
|
||||||
|
|
||||||
状态:todo 166 / doing 0 / blocked 0 / done 48 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
状态:todo 165 / doing 0 / blocked 0 / done 49 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||||
|
|
||||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
| CORE-10 | artplayer<br>迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过;接续 CORE-07 / BASE-TYPE-04 的 toggle void 与实际 Promise 分支冲突,不吞拒绝 | H | done |
|
| CORE-10 | artplayer<br>迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过;接续 CORE-07 / BASE-TYPE-04 的 toggle void 与实际 Promise 分支冲突,不吞拒绝 | H | done |
|
||||||
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | done |
|
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | done |
|
||||||
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器;复跑 BASE-05 的 SSR import/模板、浏览器 useSSR 节点复用和非浏览器构造错误;接续 VENDOR-01/02,核对 screenfull/hint 复制版本、修改与分发通知,保留 DOM/CSS/fullscreen 行为 | H | done |
|
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器;复跑 BASE-05 的 SSR import/模板、浏览器 useSSR 节点复用和非浏览器构造错误;接续 VENDOR-01/02,核对 screenfull/hint 复制版本、修改与分发通知,保留 DOM/CSS/fullscreen 行为 | H | done |
|
||||||
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值 | H | todo |
|
| CORE-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 | todo |
|
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | todo |
|
||||||
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | todo |
|
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | todo |
|
||||||
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
|
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
|
||||||
@@ -447,6 +447,7 @@
|
|||||||
- CORE-10: [记录](changes/2026-09-11-CORE-10-playback-properties.md) [记录](baselines/playback-properties-validation.json)
|
- CORE-10: [记录](changes/2026-09-11-CORE-10-playback-properties.md) [记录](baselines/playback-properties-validation.json)
|
||||||
- CORE-11: [记录](changes/2026-09-11-CORE-11-media-events.md) [记录](baselines/media-events-validation.json)
|
- CORE-11: [记录](changes/2026-09-11-CORE-11-media-events.md) [记录](baselines/media-events-validation.json)
|
||||||
- CORE-12: [记录](changes/2026-09-11-CORE-12-template-resources.md) [记录](baselines/template-resources-validation.json)
|
- CORE-12: [记录](changes/2026-09-11-CORE-12-template-resources.md) [记录](baselines/template-resources-validation.json)
|
||||||
|
- CORE-13: [记录](changes/2026-09-11-CORE-13-components.md) [记录](baselines/components-validation.json)
|
||||||
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
|
- 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-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)
|
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
|
||||||
|
|||||||
+17
-9
@@ -1,5 +1,13 @@
|
|||||||
# 进度与证据
|
# 进度与证据
|
||||||
|
|
||||||
|
## 当前实施:CORE-13 组件注册与控件完成
|
||||||
|
|
||||||
|
Component/control/layer/contextmenu 与 qualityMix 迁移 TS,拆清注册、DOM、资源、选择生命周期、进度交互和布局;修复移除后残留订阅、失败注册与重入清理、过期选择写入、selector 重用和文本属性注入。正常回调/排序/别名/描述符保持,controls.add/update 仍返回 undefined。
|
||||||
|
|
||||||
|
177 项 Node/工程/基线通过,核心及 chapter 共 110 个生产 TS 文件严格检查;最终安装 UMD 和 legacy 各 342 项三浏览器通过,实际 tarball 27 项运行时与五组类型零诊断。见 [交付](changes/2026-09-11-CORE-13-components.md) 和 [指纹及结果](baselines/components-validation.json)。关闭 BASE-LIFE-09/10/11、BASE-DOM-03/04/05;BASE-DOM-02 的窄设置面板留 CORE-14,公开返回值与只写属性声明仍交 CORE-21。
|
||||||
|
|
||||||
|
当前分支 codex/compatible-modernization,214 项中 49 完成、165 待办;核心 105 个 TS 文件、chapter 5 个。下一项 CORE-14 设置树、选择、渲染与布局;CORE-13 按单任务本地提交,未推送或发布。
|
||||||
|
|
||||||
## 当前实施:CORE-12 模板及公开资源入口完成
|
## 当前实施:CORE-12 模板及公开资源入口完成
|
||||||
|
|
||||||
模板、图标、语言和样式入口迁移 TS,保留 SSR/DOM/proxy 身份及语言分发。169 项 Node/工程/基线通过,最终安装 UMD 与 legacy 各 258 项三浏览器通过;打包 27 项运行时及五组类型零诊断。复制资源固定上游内容、全部适配和实际分发许可已核对,关闭 VENDOR-01/02 与 BASE-I18N-01;公开模板/图标类型差异 BASE-TYPE-09 交 CORE-21。见 [交付](changes/2026-09-11-CORE-12-template-resources.md)。当前 214 项,48 完成、166 待办;核心 71 个 TS 文件、chapter 5 个。下一项 CORE-13 组件注册与控件。
|
模板、图标、语言和样式入口迁移 TS,保留 SSR/DOM/proxy 身份及语言分发。169 项 Node/工程/基线通过,最终安装 UMD 与 legacy 各 258 项三浏览器通过;打包 27 项运行时及五组类型零诊断。复制资源固定上游内容、全部适配和实际分发许可已核对,关闭 VENDOR-01/02 与 BASE-I18N-01;公开模板/图标类型差异 BASE-TYPE-09 交 CORE-21。见 [交付](changes/2026-09-11-CORE-12-template-resources.md)。当前 214 项,48 完成、166 待办;核心 71 个 TS 文件、chapter 5 个。下一项 CORE-13 组件注册与控件。
|
||||||
@@ -241,15 +249,15 @@ BASE-01 已提交 `1d705b30`。BASE-HARNESS-01 完成发布包采集页面/本
|
|||||||
|
|
||||||
以下来自建立计划前的同一会话评估,属于初步诊断,不代替 BASE 阶段的已发布产物基线与完整浏览器测试:
|
以下来自建立计划前的同一会话评估,属于初步诊断,不代替 BASE 阶段的已发布产物基线与完整浏览器测试:
|
||||||
|
|
||||||
| 检查 | 结果与限制 |
|
| 检查 | 结果与限制 |
|
||||||
| --- | --- |
|
| ------------------------------------------------------------------ | ---------------------------------------------------------------- |
|
||||||
| `node --test test/playback.test.js test/dash-control.test.js` | Node 25.2.1,19 项通过;这是 mock/单元测试 |
|
| `node --test test/playback.test.js test/dash-control.test.js` | Node 25.2.1,19 项通过;这是 mock/单元测试 |
|
||||||
| 核心 src/types、test、scripts/*.js 的 ESLint(不加 --fix) | 通过;没有把插件模板占位符作为普通源码 lint |
|
| 核心 src/types、test、scripts/*.js 的 ESLint(不加 --fix) | 通过;没有把插件模板占位符作为普通源码 lint |
|
||||||
| 三个 TS 样例,TS 5.9.3、strict、types: [] | 通过;未隔离环境类型时存在第三方 DOM/WebCodecs/markdown 声明冲突 |
|
| 三个 TS 样例,TS 5.9.3、strict、types: [] | 通过;未隔离环境类型时存在第三方 DOM/WebCodecs/markdown 声明冲突 |
|
||||||
| 使用项目构建配置 write:false 进行 ESM/现代 UMD/legacy UMD 内存构建 | 成功;不是完整 npm pack 或全部发布构建;未写产物 |
|
| 使用项目构建配置 write:false 进行 ESM/现代 UMD/legacy UMD 内存构建 | 成功;不是完整 npm pack 或全部发布构建;未写产物 |
|
||||||
| 销毁源码隔离探针 | 重复 destroy 可误删其他实例注册项;需要正式回归用例 |
|
| 销毁源码隔离探针 | 重复 destroy 可误删其他实例注册项;需要正式回归用例 |
|
||||||
| 连续切源源码隔离探针 | 一次 canplay 可结算两次切源;尚未真实浏览器复现影响 |
|
| 连续切源源码隔离探针 | 一次 canplay 可结算两次切源;尚未真实浏览器复现影响 |
|
||||||
| `bun test ./test/playback.test.js ./test/dash-control.test.js` | Bun 1.3.14:1 通过、18 失败,mock.fn 不兼容;未来版本需重测 |
|
| `bun test ./test/playback.test.js ./test/dash-control.test.js` | Bun 1.3.14:1 通过、18 失败,mock.fn 不兼容;未来版本需重测 |
|
||||||
|
|
||||||
## 清点中新增的待核对项
|
## 清点中新增的待核对项
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,9 @@
|
|||||||
| BASE-LIFE-08 | resolved / 已复现 | Ready continues after a control callback destroys the instance | CORE-11 |
|
| BASE-LIFE-08 | resolved / 已复现 | Ready continues after a control callback destroys the instance | CORE-11 |
|
||||||
| BASE-I18N-01 | resolved / 已复现 | Missing prototype-named translations return inherited functions | CORE-12 |
|
| BASE-I18N-01 | resolved / 已复现 | Missing prototype-named translations return inherited functions | CORE-12 |
|
||||||
| BASE-TYPE-09 | open / 源码/产物事实 | Template and icon declarations claim incompatible DOM and static shapes | CORE-21 |
|
| BASE-TYPE-09 | open / 源码/产物事实 | Template and icon declarations claim incompatible DOM and static shapes | CORE-21 |
|
||||||
|
| BASE-LIFE-09 | resolved / 已复现 | Removed builtin controls retain subscriptions and progress drag effects | CORE-13 |
|
||||||
|
| BASE-LIFE-10 | resolved / 已复现 | Component mounting and removal failures leave partial entries or recurse | CORE-13 |
|
||||||
|
| BASE-LIFE-11 | resolved / 已复现 | Selector failures are unhandled and obsolete completion overwrites current UI | CORE-13 |
|
||||||
|
| BASE-DOM-03 | resolved / 已复现 | Reusing selector items after update redefines nonconfigurable properties | CORE-13 |
|
||||||
|
| BASE-DOM-04 | resolved / 已复现 | A component named __proto__ changes its registry prototype | CORE-13 |
|
||||||
|
| BASE-DOM-05 | resolved / 已复现 | Highlight text interpolation permits unintended HTML attributes | CORE-13 |
|
||||||
+140
-1
@@ -170,7 +170,8 @@
|
|||||||
"refactor/baselines/dom.json"
|
"refactor/baselines/dom.json"
|
||||||
],
|
],
|
||||||
"compatibleResolution": "在保留自定义控件扩展入口下修正布局。",
|
"compatibleResolution": "在保留自定义控件扩展入口下修正布局。",
|
||||||
"closureCriteria": "320px 及正常容器的设置/模式操作真实可用。"
|
"closureCriteria": "320px 及正常容器的设置/模式操作真实可用。",
|
||||||
|
"workspaceState": "CORE-13 final installed UMD and legacy verify wrapped controls at 640/320/240px with real 16:9 heights and observed total-height offsets, including ResizeObserver fallback. Narrow setting tree/panel interactions still require CORE-14; this finding remains open."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "BASE-DEMO-01",
|
"id": "BASE-DEMO-01",
|
||||||
@@ -993,6 +994,144 @@
|
|||||||
],
|
],
|
||||||
"compatibleResolution": "Keep old legal declarations accepted, expose accurate template and icon views without inventing runtime properties.",
|
"compatibleResolution": "Keep old legal declarations accepted, expose accurate template and icon views without inventing runtime properties.",
|
||||||
"closureCriteria": "Old consumers and accurate template/media/icon view consumers pass all supported type modes and runtime shape checks."
|
"closureCriteria": "Old consumers and accurate template/media/icon view consumers pass all supported type modes and runtime shape checks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASE-LIFE-09",
|
||||||
|
"title": "Removed builtin controls retain subscriptions and progress drag effects",
|
||||||
|
"confirmation": "reproduced",
|
||||||
|
"status": "resolved",
|
||||||
|
"owners": [
|
||||||
|
"CORE-13"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js",
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md"
|
||||||
|
],
|
||||||
|
"compatibleResolution": "Keep per-entry owned listeners, timers and drag callbacks inert after removal while preserving user subscriptions.",
|
||||||
|
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
|
||||||
|
"resolutionEvidence": [
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"refactor/baselines/components-validation.json",
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js"
|
||||||
|
],
|
||||||
|
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASE-LIFE-10",
|
||||||
|
"title": "Component mounting and removal failures leave partial entries or recurse",
|
||||||
|
"confirmation": "reproduced",
|
||||||
|
"status": "resolved",
|
||||||
|
"owners": [
|
||||||
|
"CORE-13"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js",
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md"
|
||||||
|
],
|
||||||
|
"compatibleResolution": "Rollback failed registration, preserve successful reentrant replacement and original errors, retain failed beforeUnmount for retry.",
|
||||||
|
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
|
||||||
|
"resolutionEvidence": [
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"refactor/baselines/components-validation.json",
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js"
|
||||||
|
],
|
||||||
|
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASE-LIFE-11",
|
||||||
|
"title": "Selector failures are unhandled and obsolete completion overwrites current UI",
|
||||||
|
"confirmation": "reproduced",
|
||||||
|
"status": "resolved",
|
||||||
|
"owners": [
|
||||||
|
"CORE-13"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js",
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md"
|
||||||
|
],
|
||||||
|
"compatibleResolution": "Keep callback arguments and default flags; report owned failures and gate titles and builtin quality notices by active selection.",
|
||||||
|
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
|
||||||
|
"resolutionEvidence": [
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"refactor/baselines/components-validation.json",
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js"
|
||||||
|
],
|
||||||
|
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASE-DOM-03",
|
||||||
|
"title": "Reusing selector items after update redefines nonconfigurable properties",
|
||||||
|
"confirmation": "reproduced",
|
||||||
|
"status": "resolved",
|
||||||
|
"owners": [
|
||||||
|
"CORE-13"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js",
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md"
|
||||||
|
],
|
||||||
|
"compatibleResolution": "Keep getter descriptors and rebind only after the previous entry closes.",
|
||||||
|
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
|
||||||
|
"resolutionEvidence": [
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"refactor/baselines/components-validation.json",
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js"
|
||||||
|
],
|
||||||
|
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASE-DOM-04",
|
||||||
|
"title": "A component named __proto__ changes its registry prototype",
|
||||||
|
"confirmation": "reproduced",
|
||||||
|
"status": "resolved",
|
||||||
|
"owners": [
|
||||||
|
"CORE-13"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js",
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md"
|
||||||
|
],
|
||||||
|
"compatibleResolution": "Preserve named DOM access with a normal own property without changing registry prototype.",
|
||||||
|
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
|
||||||
|
"resolutionEvidence": [
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"refactor/baselines/components-validation.json",
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js"
|
||||||
|
],
|
||||||
|
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BASE-DOM-05",
|
||||||
|
"title": "Highlight text interpolation permits unintended HTML attributes",
|
||||||
|
"confirmation": "reproduced",
|
||||||
|
"status": "resolved",
|
||||||
|
"owners": [
|
||||||
|
"CORE-13"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js",
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md"
|
||||||
|
],
|
||||||
|
"compatibleResolution": "Assign highlight text through dataset while retaining marker positions and intentional control HTML APIs.",
|
||||||
|
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
|
||||||
|
"resolutionEvidence": [
|
||||||
|
"refactor/changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"refactor/baselines/components-validation.json",
|
||||||
|
"test/browser/components.spec.js",
|
||||||
|
"test/component-resources.test.js"
|
||||||
|
],
|
||||||
|
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+5
-2
@@ -990,11 +990,14 @@
|
|||||||
"CORE-12",
|
"CORE-12",
|
||||||
"CORE-08"
|
"CORE-08"
|
||||||
],
|
],
|
||||||
"status": "todo",
|
"status": "done",
|
||||||
"risk": "H",
|
"risk": "H",
|
||||||
"deliverable": "Component/control/layer/contextmenu 的资源与 TS 边界",
|
"deliverable": "Component/control/layer/contextmenu 的资源与 TS 边界",
|
||||||
"acceptance": "add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值",
|
"acceptance": "add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值",
|
||||||
"evidence": []
|
"evidence": [
|
||||||
|
"changes/2026-09-11-CORE-13-components.md",
|
||||||
|
"baselines/components-validation.json"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CORE-14",
|
"id": "CORE-14",
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { expect, test } from './fixtures.js'
|
||||||
|
|
||||||
|
for (const fallback of [false, true]) {
|
||||||
|
test(`candidate: control layout follows resize and removal with observer fallback=${fallback}`, async ({ page }) => {
|
||||||
|
if (fallback) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.ResizeObserver = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, fullscreenWeb: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }] })
|
||||||
|
})
|
||||||
|
const heights = () => page.evaluate(() => ({ actual: window.art.template.$controls.offsetHeight, recorded: Number.parseFloat(window.art.template.$player.style.getPropertyValue('--art-controls-height')) }))
|
||||||
|
await expect.poll(heights).toEqual({ actual: 46, recorded: 46 })
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.art.template.$container.style.width = '320px'
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
})
|
||||||
|
await expect.poll(heights).toEqual({ actual: 92, recorded: 92 })
|
||||||
|
await expect.poll(() => page.evaluate(() => Number.parseFloat(getComputedStyle(window.art.template.$subtitle).bottom))).toBeGreaterThanOrEqual(92)
|
||||||
|
await page.evaluate(() => window.art.controls.remove('probe'))
|
||||||
|
await expect.poll(heights).toEqual({ actual: 46, recorded: 46 })
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.art.destroy(false)
|
||||||
|
window.art.template.$controls.style.display = 'none'
|
||||||
|
window.art.emit('resize')
|
||||||
|
})
|
||||||
|
await expect.poll(heights).toEqual({ actual: 0, recorded: 46 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const core of ['published', 'candidate']) {
|
||||||
|
test(`${core}: selector background clicks and rejection ownership are explicit`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const failure = new Error('selection failure')
|
||||||
|
window.selectionReports = { warnings: [], rejections: [] }
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
window.selectionReports.rejections.push({ name: event.reason.name, same: event.reason === failure })
|
||||||
|
})
|
||||||
|
const original = console.warn
|
||||||
|
console.warn = (message, error) => {
|
||||||
|
window.selectionReports.warnings.push({ message, same: error === failure })
|
||||||
|
original.call(console, message, error)
|
||||||
|
}
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
window.art = art
|
||||||
|
art.controls.add({ name: 'selector', position: 'right', html: 'initial', selector: [{ html: 'A' }], onSelect() {
|
||||||
|
return Promise.reject(failure)
|
||||||
|
} })
|
||||||
|
art.controls.selector.querySelector('.art-selector-list').dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
})
|
||||||
|
if (core === 'published')
|
||||||
|
await expect.poll(() => page.evaluate(() => window.selectionReports.rejections.length)).toBe(1)
|
||||||
|
await page.evaluate(() => window.art.controls.selector.querySelector('.art-selector-item').dispatchEvent(new MouseEvent('click', { bubbles: true })))
|
||||||
|
await expect.poll(() => page.evaluate(() => window.selectionReports.warnings.length + window.selectionReports.rejections.length)).toBe(core === 'published' ? 2 : 1)
|
||||||
|
const reports = await page.evaluate(() => window.selectionReports)
|
||||||
|
expect(reports).toEqual(core === 'published'
|
||||||
|
? { warnings: [], rejections: [{ name: 'TypeError', same: false }, { name: 'Error', same: true }] }
|
||||||
|
: { warnings: [{ message: 'ArtPlayer selector failed:', same: true }], rejections: [] })
|
||||||
|
await page.evaluate(() => window.art.destroy())
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: beforeUnmount errors remain retryable and detached nodes can be removed`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const failure = new Error('before unmount')
|
||||||
|
const option = { name: 'entry', html: 'entry', beforeUnmount() {
|
||||||
|
throw failure
|
||||||
|
} }
|
||||||
|
const element = art.layers.add(option)
|
||||||
|
let same = false
|
||||||
|
try {
|
||||||
|
art.layers.remove('entry')
|
||||||
|
}
|
||||||
|
catch (error) { same = error === failure }
|
||||||
|
const retained = art.layers.cache.has('entry') && element.isConnected
|
||||||
|
option.beforeUnmount = () => {}
|
||||||
|
element.remove()
|
||||||
|
let error = null
|
||||||
|
try {
|
||||||
|
art.layers.remove('entry')
|
||||||
|
}
|
||||||
|
catch (caught) { error = caught.name }
|
||||||
|
const result = { same, retained, error, removed: !art.layers.cache.has('entry') }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ same: true, retained: true, error: core === 'published' ? 'TypeError' : null, removed: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: failed mounted reentry preserves its successful replacement`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const failure = new Error('outer failure')
|
||||||
|
let same = false
|
||||||
|
try {
|
||||||
|
art.layers.add({ name: 'entry', html: 'outer', mounted() {
|
||||||
|
art.layers.remove('entry')
|
||||||
|
art.layers.add({ name: 'entry', html: 'replacement' })
|
||||||
|
throw failure
|
||||||
|
} })
|
||||||
|
}
|
||||||
|
catch (error) { same = error === failure }
|
||||||
|
const result = { same, value: art.layers.entry.textContent, attached: art.layers.entry.isConnected, entries: art.layers.cache.size }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ same: true, value: 'replacement', attached: true, entries: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: context menu builtin selection and owned updates preserve state`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', playbackRate: true, aspectRatio: true, flip: true })
|
||||||
|
})
|
||||||
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||||
|
for (const [name, value] of [['playbackRate', '1.5'], ['aspectRatio', '4:3'], ['flip', 'horizontal']]) {
|
||||||
|
await page.locator('.art-video-player').click({ button: 'right', position: { x: 100, y: 80 } })
|
||||||
|
await page.locator(`.art-contextmenu-${name} [data-value="${value}"]`).click()
|
||||||
|
await expect.poll(() => page.evaluate(name => window.art.contextmenu[name].querySelector('.art-current')?.dataset.value, name)).toBe(value)
|
||||||
|
expect(await page.evaluate(() => window.art.contextmenu.show)).toBe(false)
|
||||||
|
}
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const { art } = window
|
||||||
|
const menu = art.contextmenu.flip
|
||||||
|
art.contextmenu.remove('flip')
|
||||||
|
art.flip = 'normal'
|
||||||
|
const result = { rate: art.playbackRate, aspect: art.aspectRatio, changedAfterRemove: menu.querySelector('[data-value="normal"]').classList.contains('art-current') }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ rate: 1.5, aspect: '4:3', changedAfterRemove: core === 'published' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: a prototype-named component keeps the registry prototype intact`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const prototype = Object.getPrototypeOf(art.layers)
|
||||||
|
const element = art.layers.add({ name: '__proto__', html: 'named' })
|
||||||
|
const alias = Reflect.get(art.layers, '__proto__') === element
|
||||||
|
art.layers.remove('__proto__')
|
||||||
|
const result = { alias, preserved: Object.getPrototypeOf(art.layers) === prototype }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ alias: true, preserved: core === 'candidate' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: component factories, ordering, bound methods and update hooks retain contracts`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const { add, update, remove } = art.layers
|
||||||
|
const calls = []
|
||||||
|
const first = { name: 'first', index: 10, html: 'first', mounted(element) {
|
||||||
|
calls.push(['mount', this === art, element.textContent])
|
||||||
|
} }
|
||||||
|
add(first)
|
||||||
|
add({ name: 'equal', index: 10, html: 'equal' })
|
||||||
|
let factory
|
||||||
|
add((owner) => {
|
||||||
|
factory = owner === art
|
||||||
|
return { name: 'zero', index: 0, html: 42 }
|
||||||
|
})
|
||||||
|
const disabled = { html: 0, disable: true }
|
||||||
|
const before = art.layers.id
|
||||||
|
const disabledResult = add(disabled)
|
||||||
|
const order = Array.from(art.template.$layer.children, node => node.textContent)
|
||||||
|
const updated = update({ name: 'first', html: 'changed', beforeUnmount(element) {
|
||||||
|
calls.push(['unmount', this === art, element.textContent, first.html])
|
||||||
|
} })
|
||||||
|
const controlResult = art.controls.add({ name: 'probe', position: 'right', html: 'probe', click(component, event) {
|
||||||
|
calls.push(['click', this === art, component === art.controls, event.defaultPrevented])
|
||||||
|
} })
|
||||||
|
art.controls.probe.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||||
|
const controlUpdate = art.controls.update({ name: 'probe', html: 'updated' })
|
||||||
|
const result = { factory, order, disabled: disabledResult === undefined && art.layers.id === before + 1 && disabled.html === '', updated: updated === art.layers.first, controlReturns: [controlResult === undefined, controlUpdate === undefined], calls }
|
||||||
|
remove('first')
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result.factory).toBe(true)
|
||||||
|
expect(result.order).toEqual(['42', 'equal', 'first'])
|
||||||
|
expect(result.disabled).toBe(true)
|
||||||
|
expect(result.updated).toBe(true)
|
||||||
|
expect(result.controlReturns).toEqual([true, true])
|
||||||
|
expect(result.calls.slice(0, 4)).toEqual([['mount', true, 'first'], ['unmount', true, 'first', 'changed'], ['mount', true, 'changed'], ['click', true, true, true]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: removed builtin time and volume controls stop owned effects`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const time = art.controls.time
|
||||||
|
const before = time.textContent
|
||||||
|
const icon = art.controls.volume.querySelector('.art-icon-volume')
|
||||||
|
art.controls.remove('time')
|
||||||
|
art.controls.remove('volume')
|
||||||
|
art.video.currentTime = 12
|
||||||
|
art.emit('video:timeupdate', new Event('timeupdate'))
|
||||||
|
art.muted = false
|
||||||
|
icon.dispatchEvent(new MouseEvent('click'))
|
||||||
|
const result = { changed: time.textContent !== before, muted: art.muted }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ changed: core === 'published', muted: core === 'published' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: selector items can be reused by an update without changing getter flags`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const selector = [{ html: 'A', value: 'a', default: true }, { html: 'B', value: 'b' }]
|
||||||
|
art.controls.add({ name: 'selector', position: 'right', html: 'A', selector })
|
||||||
|
const oldNode = selector[0].$control_item
|
||||||
|
let error = null
|
||||||
|
try {
|
||||||
|
art.controls.update({ name: 'selector', html: 'updated' })
|
||||||
|
}
|
||||||
|
catch (caught) { error = caught.name }
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(selector[0], '$control_item')
|
||||||
|
const result = { error, changed: selector[0].$control_item !== oldNode, flags: [descriptor.enumerable, descriptor.configurable, typeof descriptor.get], options: selector[0].$control_option === selector }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ error: core === 'candidate' ? null : 'TypeError', changed: core === 'candidate', flags: [false, false, 'function'], options: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: async selector results cannot overwrite a newer choice or removed entry`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const selector = [{ html: 'A', value: 'a' }, { html: 'B', value: 'b' }]
|
||||||
|
const pending = []
|
||||||
|
const calls = []
|
||||||
|
art.controls.add({ name: 'selector', position: 'right', html: 'initial', selector, onSelect(item, node, event) {
|
||||||
|
calls.push([this === art, selector.includes(item), item.$control_item === node, event.type])
|
||||||
|
return new Promise(resolve => pending.push(resolve))
|
||||||
|
} })
|
||||||
|
const click = item => item.$control_item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
click(selector[0])
|
||||||
|
click(selector[1])
|
||||||
|
pending[1]('new')
|
||||||
|
await Promise.resolve()
|
||||||
|
pending[0]('old')
|
||||||
|
await Promise.resolve()
|
||||||
|
const value = selector[0].$control_value
|
||||||
|
const raced = value.innerHTML
|
||||||
|
click(selector[0])
|
||||||
|
const beforeRemove = value.innerHTML
|
||||||
|
art.controls.remove('selector')
|
||||||
|
pending[2]('removed')
|
||||||
|
await Promise.resolve()
|
||||||
|
const result = { raced, changedAfterRemove: value.innerHTML !== beforeRemove, flags: selector.map(item => item.default), calls }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result.raced).toBe(core === 'candidate' ? 'new' : 'old')
|
||||||
|
expect(result.changedAfterRemove).toBe(core === 'published')
|
||||||
|
expect(result.flags).toEqual([true, false])
|
||||||
|
expect(result.calls).toEqual(Array.from({ length: 3 }, () => [true, true, true, 'click']))
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: a failed mounted hook rolls back its DOM alias and listeners`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
const failure = new Error('mounted failure')
|
||||||
|
let element
|
||||||
|
let sameError = false
|
||||||
|
let clicks = 0
|
||||||
|
try {
|
||||||
|
art.layers.add({ name: 'failed', html: 'failed', click() {
|
||||||
|
clicks++
|
||||||
|
}, mounted(node) {
|
||||||
|
element = node
|
||||||
|
throw failure
|
||||||
|
} })
|
||||||
|
}
|
||||||
|
catch (error) { sameError = error === failure }
|
||||||
|
element.dispatchEvent(new MouseEvent('click'))
|
||||||
|
const result = { sameError, clicks, cached: art.layers.cache.has('failed'), alias: art.layers.failed === element, attached: art.template.$layer.contains(element) }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ sameError: true, clicks: core === 'candidate' ? 0 : 1, cached: core === 'published', alias: core === 'published', attached: core === 'published' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: reentrant beforeUnmount removes only once`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '' })
|
||||||
|
let calls = 0
|
||||||
|
art.layers.add({ name: 'reentrant', html: 'reentrant', beforeUnmount() {
|
||||||
|
calls++
|
||||||
|
if (calls === 1)
|
||||||
|
art.layers.remove('reentrant')
|
||||||
|
} })
|
||||||
|
let error = null
|
||||||
|
try {
|
||||||
|
art.layers.remove('reentrant')
|
||||||
|
}
|
||||||
|
catch (caught) { error = caught.name }
|
||||||
|
const result = { calls, error, cached: art.layers.cache.has('reentrant') }
|
||||||
|
art.destroy()
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ calls: core === 'candidate' ? 1 : 2, error: core === 'candidate' ? null : 'TypeError', cached: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: narrow controls stay inside the player without hiding buttons`, async ({ page }, testInfo) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = new window.Artplayer({ container: '.player', url: '', setting: true, fullscreenWeb: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }] })
|
||||||
|
const container = art.template.$container
|
||||||
|
const measure = (width) => {
|
||||||
|
container.style.width = `${width}px`
|
||||||
|
container.style.height = `${width * 9 / 16}px`
|
||||||
|
const bounds = container.getBoundingClientRect()
|
||||||
|
const controls = Array.from(container.querySelectorAll('.art-controls .art-control'))
|
||||||
|
return { count: controls.length, hidden: controls.filter(node => node.getClientRects().length === 0).length, overflow: controls.filter((node) => {
|
||||||
|
const box = node.getBoundingClientRect()
|
||||||
|
return box.left < bounds.left - 0.75 || box.right > bounds.right + 0.75 || box.top < bounds.top - 0.75 || box.bottom > bounds.bottom + 0.75
|
||||||
|
}).length }
|
||||||
|
}
|
||||||
|
const wide = measure(640)
|
||||||
|
const narrow = measure(320)
|
||||||
|
const smaller = measure(240)
|
||||||
|
measure(320)
|
||||||
|
window.layoutArt = art
|
||||||
|
return { wide, narrow, smaller }
|
||||||
|
})
|
||||||
|
await page.locator('.player').screenshot({ path: testInfo.outputPath('narrow-controls.png') })
|
||||||
|
await page.evaluate(() => window.layoutArt.destroy())
|
||||||
|
expect(result.wide.overflow).toBe(0)
|
||||||
|
expect(result.narrow.count).toBe(result.wide.count)
|
||||||
|
expect(result.narrow.hidden).toBe(0)
|
||||||
|
expect(result.smaller.hidden).toBe(0)
|
||||||
|
if (core === 'candidate') {
|
||||||
|
expect(result.narrow.overflow).toBe(0)
|
||||||
|
expect(result.smaller.overflow).toBe(0)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expect(result.narrow.overflow).toBeGreaterThan(0)
|
||||||
|
expect(result.smaller.overflow).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${core}: progress markers treat text as data and removed drags cannot seek`, async ({ page }) => {
|
||||||
|
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.createPlayer('/test/pattern.mp4')
|
||||||
|
window.art.option.highlight = [{ time: 1, text: 'label" data-injected="yes' }]
|
||||||
|
})
|
||||||
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const art = window.art
|
||||||
|
const marker = art.controls.progress.querySelector('.art-progress-highlight span')
|
||||||
|
const text = marker.dataset.text
|
||||||
|
const injected = marker.hasAttribute('data-injected')
|
||||||
|
let seeks = 0
|
||||||
|
art.on('seek', () => seeks++)
|
||||||
|
art.template.$progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
|
||||||
|
art.controls.remove('progress')
|
||||||
|
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 100 }))
|
||||||
|
art.destroy()
|
||||||
|
return { text, injected, seeks }
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ text: core === 'candidate' ? 'label" data-injected="yes' : 'label', injected: core === 'published', seeks: core === 'candidate' ? 0 : 1 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -56,11 +56,32 @@ for (const core of ['published', 'candidate']) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }) => {
|
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }, testInfo) => {
|
||||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
|
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
|
||||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||||
|
// A real gesture starts WebKit's media pipeline before the paused seek setup.
|
||||||
|
await page.locator('#play').click()
|
||||||
|
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||||
|
await page.locator('#pause').click()
|
||||||
|
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
|
||||||
|
await testInfo.attach('initial-media-ranges', {
|
||||||
|
body: JSON.stringify(await page.evaluate(() => {
|
||||||
|
const { video } = window.art
|
||||||
|
const ranges = value => Array.from({ length: value.length }, (_, index) => [value.start(index), value.end(index)])
|
||||||
|
return { buffered: ranges(video.buffered), seekable: ranges(video.seekable), readyState: video.readyState, duration: video.duration, currentTime: video.currentTime }
|
||||||
|
})),
|
||||||
|
contentType: 'application/json',
|
||||||
|
})
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const ranges = window.art.video.seekable
|
||||||
|
return Array.from({ length: ranges.length }, (_, index) => index).some(index => ranges.start(index) <= 3 && ranges.end(index) >= 3)
|
||||||
|
})).toBe(true)
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
window.switchMediaEvents = []
|
||||||
|
for (const name of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'pause', 'playing']) {
|
||||||
|
window.art.video.addEventListener(name, () => window.switchMediaEvents.push({ name, time: window.art.currentTime, seeking: window.art.video.seeking, source: window.art.video.currentSrc }))
|
||||||
|
}
|
||||||
window.art.currentTime = 3
|
window.art.currentTime = 3
|
||||||
})
|
})
|
||||||
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
|
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
|
||||||
@@ -76,11 +97,16 @@ test('candidate: quality switch restores position/rate and preserves real custom
|
|||||||
art.aspectRatio = '16:9'
|
art.aspectRatio = '16:9'
|
||||||
const restart = []
|
const restart = []
|
||||||
art.on('restart', url => restart.push(url))
|
art.on('restart', url => restart.push(url))
|
||||||
|
const beforeSwitch = { time: art.currentTime, seeking: art.video.seeking, paused: art.video.paused }
|
||||||
await art.switchQuality('/test/pattern.mp4?custom=next')
|
await art.switchQuality('/test/pattern.mp4?custom=next')
|
||||||
const result = { calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
|
const result = { beforeSwitch, mediaEvents: window.switchMediaEvents, calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
|
||||||
art.destroy()
|
art.destroy()
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
await testInfo.attach('quality-switch-media-state', { body: JSON.stringify(result), contentType: 'application/json' })
|
||||||
|
expect(result.beforeSwitch.time).toBeCloseTo(3, 1)
|
||||||
|
expect(result.beforeSwitch.seeking).toBe(false)
|
||||||
|
expect(result.beforeSwitch.paused).toBe(true)
|
||||||
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
|
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
|
||||||
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
|
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
|
||||||
expect(result.time).toBeCloseTo(3, 1)
|
expect(result.time).toBeCloseTo(3, 1)
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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 resources = 'packages/artplayer/src/component/resources'
|
||||||
|
const lifecycle = 'packages/artplayer/src/lifecycle/instance'
|
||||||
|
const { Emitter, Control, installControls, qualityMix, trackSelection, captureSelection, beginLifecycle, destroyInstance, getScope, ownEntry, entryScope, releaseEntry, proxyEntry, subscribeEntry } = await loadModules({
|
||||||
|
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||||
|
Control: 'packages/artplayer/src/control/index',
|
||||||
|
installControls: { file: 'packages/artplayer/src/control/builtins', name: 'installControls' },
|
||||||
|
qualityMix: 'packages/artplayer/src/player/qualityMix',
|
||||||
|
...Object.fromEntries(['trackSelection', 'captureSelection'].map(name => [name, { file: 'packages/artplayer/src/component/selection', name }])),
|
||||||
|
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'getScope'].map(name => [name, { file: lifecycle, name }])),
|
||||||
|
...Object.fromEntries(['ownEntry', 'entryScope', 'releaseEntry', 'proxyEntry', 'subscribeEntry'].map(name => [name, { file: resources, name }])),
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deferred builtin quality initialization reports its original failure', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { art } = fixture()
|
||||||
|
const failure = new Error('quality option getter')
|
||||||
|
const warnings = []
|
||||||
|
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||||
|
let reads = 0
|
||||||
|
art.option = {
|
||||||
|
controls: [],
|
||||||
|
get quality() {
|
||||||
|
if (reads++ === 0)
|
||||||
|
return [{ html: 'A', url: 'a' }]
|
||||||
|
throw failure
|
||||||
|
},
|
||||||
|
}
|
||||||
|
installControls({ art, add() {} })
|
||||||
|
t.mock.timers.tick(0)
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
assert.deepEqual(warnings, [['ArtPlayer quality initialization failed:', failure]])
|
||||||
|
getScope(art).dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('control factories do not run during the reset portion of instance destruction', () => {
|
||||||
|
const { art } = fixture()
|
||||||
|
art.isDestroy = false
|
||||||
|
art.template = { $video: {}, destroy() {} }
|
||||||
|
let factories = 0
|
||||||
|
art.reset = () => {
|
||||||
|
assert.equal(art.isDestroy, false)
|
||||||
|
assert.equal(getScope(art).closed, false)
|
||||||
|
const result = Control.prototype.add.call({ art }, () => {
|
||||||
|
factories++
|
||||||
|
return { html: 'late' }
|
||||||
|
})
|
||||||
|
assert.equal(result, undefined)
|
||||||
|
}
|
||||||
|
destroyInstance(art, [art], true, true)
|
||||||
|
assert.equal(factories, 0)
|
||||||
|
assert.equal(art.isDestroy, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('quality callbacks keep return values while obsolete and removed selections stop notices', async () => {
|
||||||
|
const { art, element } = fixture()
|
||||||
|
let option
|
||||||
|
art.controls = { update(value) {
|
||||||
|
option = value
|
||||||
|
} }
|
||||||
|
art.notice = { show: '' }
|
||||||
|
art.i18n = { get: key => key }
|
||||||
|
const pending = []
|
||||||
|
art.switchQuality = url => new Promise(resolve => pending.push({ url, resolve }))
|
||||||
|
qualityMix(art)
|
||||||
|
const quality = [{ html: 'A', url: 'a' }, { html: 'B', url: 'b' }]
|
||||||
|
art.quality = quality
|
||||||
|
let generation = 0
|
||||||
|
const run = (item) => {
|
||||||
|
const current = ++generation
|
||||||
|
const scope = entryScope(element)
|
||||||
|
const event = new Event('click')
|
||||||
|
const release = scope.add(trackSelection(event, () => !scope.closed && current === generation))
|
||||||
|
return option.onSelect.call(art, item, element, event).finally(release)
|
||||||
|
}
|
||||||
|
const first = run(quality[0])
|
||||||
|
const second = run(quality[1])
|
||||||
|
pending[1].resolve()
|
||||||
|
assert.equal(await second, 'B')
|
||||||
|
assert.equal(art.notice.show, 'Switch Video: B')
|
||||||
|
pending[0].resolve()
|
||||||
|
assert.equal(await first, 'A')
|
||||||
|
assert.equal(art.notice.show, 'Switch Video: B')
|
||||||
|
const removed = run(quality[0])
|
||||||
|
releaseEntry(element)
|
||||||
|
pending[2].resolve()
|
||||||
|
assert.equal(await removed, 'A')
|
||||||
|
assert.equal(art.notice.show, 'Switch Video: B')
|
||||||
|
ownEntry(art, element)
|
||||||
|
art.i18n.get = (key) => {
|
||||||
|
releaseEntry(element)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
const reentrant = run(quality[0])
|
||||||
|
pending[3].resolve()
|
||||||
|
assert.equal(await reentrant, 'A')
|
||||||
|
assert.equal(art.notice.show, 'Switch Video: B')
|
||||||
|
assert.deepEqual(pending.map(item => item.url), ['a', 'b', 'a', 'a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('quality rejection identity and captured selection closure survive disposal', async () => {
|
||||||
|
const { art, element } = fixture()
|
||||||
|
let option
|
||||||
|
const failure = new Error('quality failed')
|
||||||
|
art.controls = { update(value) {
|
||||||
|
option = value
|
||||||
|
} }
|
||||||
|
art.notice = { show: '' }
|
||||||
|
art.i18n = { get: key => key }
|
||||||
|
art.switchQuality = () => Promise.reject(failure)
|
||||||
|
qualityMix(art)
|
||||||
|
const quality = [{ html: 'A', url: 'a' }]
|
||||||
|
art.quality = quality
|
||||||
|
const event = new Event('click')
|
||||||
|
const scope = entryScope(element)
|
||||||
|
scope.add(trackSelection(event, () => !scope.closed))
|
||||||
|
const active = captureSelection(event)
|
||||||
|
const result = option.onSelect.call(art, quality[0], element, event)
|
||||||
|
releaseEntry(element)
|
||||||
|
assert.equal(active(), false)
|
||||||
|
await assert.rejects(result, error => error === failure)
|
||||||
|
assert.equal(art.notice.show, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const art = new Emitter()
|
||||||
|
const registered = new Set()
|
||||||
|
art.events = {
|
||||||
|
proxy(target, name, callback) {
|
||||||
|
target.addEventListener(name, callback)
|
||||||
|
const cleanup = () => target.removeEventListener(name, callback)
|
||||||
|
registered.add(cleanup)
|
||||||
|
return cleanup
|
||||||
|
},
|
||||||
|
remove(cleanup) {
|
||||||
|
cleanup()
|
||||||
|
registered.delete(cleanup)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
beginLifecycle(art)
|
||||||
|
const element = new EventTarget()
|
||||||
|
ownEntry(art, element)
|
||||||
|
return { art, element, registered }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('entry disposal removes DOM listeners and preserves unrelated entries', () => {
|
||||||
|
const { art, element, registered } = fixture()
|
||||||
|
const second = new EventTarget()
|
||||||
|
ownEntry(art, second)
|
||||||
|
const events = []
|
||||||
|
proxyEntry(art, element, element, 'click', event => events.push(event))
|
||||||
|
proxyEntry(art, second, second, 'click', event => events.push(event))
|
||||||
|
const original = new Event('click')
|
||||||
|
element.dispatchEvent(original)
|
||||||
|
assert.equal(events[0], original)
|
||||||
|
releaseEntry(element)
|
||||||
|
element.dispatchEvent(new Event('click'))
|
||||||
|
second.dispatchEvent(new Event('click'))
|
||||||
|
assert.equal(events.length, 2)
|
||||||
|
assert.equal(registered.size, 1)
|
||||||
|
assert.equal(entryScope(element).closed, true)
|
||||||
|
assert.equal(getScope(art).closed, false)
|
||||||
|
getScope(art).dispose()
|
||||||
|
assert.equal(registered.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entry listeners stop within an existing emitter snapshot while user listeners survive', () => {
|
||||||
|
const { art, element } = fixture()
|
||||||
|
const calls = []
|
||||||
|
art.on('update', () => {
|
||||||
|
calls.push('first')
|
||||||
|
releaseEntry(element)
|
||||||
|
})
|
||||||
|
subscribeEntry(art, element, 'update', () => calls.push('owned'))
|
||||||
|
art.on('update', () => calls.push('last'))
|
||||||
|
art.emit('update')
|
||||||
|
art.emit('update')
|
||||||
|
assert.deepEqual(calls, ['first', 'last', 'first', 'last'])
|
||||||
|
assert.equal(art.e.update.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('root closure stops all entry resources and late registrations remain inert', () => {
|
||||||
|
const { art, element, registered } = fixture()
|
||||||
|
let calls = 0
|
||||||
|
proxyEntry(art, element, element, 'click', () => calls++)
|
||||||
|
subscribeEntry(art, element, 'update', () => calls++)
|
||||||
|
getScope(art).dispose()
|
||||||
|
proxyEntry(art, element, element, 'click', () => calls++)
|
||||||
|
subscribeEntry(art, element, 'update', () => calls++)
|
||||||
|
element.dispatchEvent(new Event('click'))
|
||||||
|
art.emit('update')
|
||||||
|
releaseEntry(element)
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(registered.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cleanup failure does not prevent releasing other component resources', () => {
|
||||||
|
const { art, element } = fixture()
|
||||||
|
const failures = [new Error('first'), new Error('second')]
|
||||||
|
const attempts = []
|
||||||
|
art.events.remove = () => {
|
||||||
|
const error = failures[attempts.length]
|
||||||
|
attempts.push(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
proxyEntry(art, element, element, 'click', () => {})
|
||||||
|
proxyEntry(art, element, element, 'change', () => {})
|
||||||
|
assert.throws(() => releaseEntry(element), (error) => {
|
||||||
|
assert.deepEqual(error.errors, failures)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.deepEqual(attempts, failures)
|
||||||
|
assert.equal(entryScope(element).closed, true)
|
||||||
|
releaseEntry(element)
|
||||||
|
assert.equal(attempts.length, 2)
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ComponentHost } from '../../packages/artplayer/src/component/types'
|
||||||
|
import type { ControlHost } from '../../packages/artplayer/src/control/types'
|
||||||
|
import Control from '../../packages/artplayer/src/control'
|
||||||
|
import Component from '../../packages/artplayer/src/utils/component'
|
||||||
|
|
||||||
|
declare const host: ComponentHost & { extra: number }
|
||||||
|
const component = new Component(host)
|
||||||
|
const added: HTMLDivElement | undefined = component.add({ html: 42, mounted(element) {
|
||||||
|
const extra: number = this.extra
|
||||||
|
const div: HTMLDivElement = element
|
||||||
|
void [extra, div]
|
||||||
|
} })
|
||||||
|
const updated: HTMLDivElement | undefined = component.update({ name: 'entry', html: 'new' })
|
||||||
|
declare const controlHost: ControlHost
|
||||||
|
const control = new Control(controlHost)
|
||||||
|
const empty: undefined = control.add({ position: 'right', html: 'entry' })
|
||||||
|
const alsoEmpty: undefined = control.update({ name: 'entry', html: 'new' })
|
||||||
|
// @ts-expect-error Control.add has always returned undefined at runtime.
|
||||||
|
const invented: HTMLDivElement = control.add({ position: 'left', html: 'entry' })
|
||||||
|
// @ts-expect-error Component indexes are numbers.
|
||||||
|
component.add({ index: 'first' })
|
||||||
|
void [added, updated, empty, alsoEmpty, invented]
|
||||||
Reference in new issue
Block a user