refactor(core): CORE-17 type input events and own interaction lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 11:24:15 +08:00
1 parent 88aef830df
commit a842058136
72 files changed
+31156 -1219

No files matched your search

+126
View File
@@ -4,6 +4,132 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
The production entry is still `src/index.js`; this document marks actual migrated
boundaries rather than describing the entire core as TypeScript.
## Keyboard input (CORE-17)
hotkey.ts retains the art/keys own fields, init/add/remove prototype methods, plain
keys-object prototype, caller-visible callback arrays and chainable registration.
input/hotkey-types.ts describes its minimal generic host and callback receiver;
input/hotkey-defaults.ts creates stable default handlers so repeated init neither
multiplies callbacks nor prevents explicitly restoring removed defaults. Public init
still enables manual mobile keyboard handling; automatic construction stays desktop-only.
input/keyboard-focus.ts resolves the event document, falls back to the player's current
ownerDocument and follows open shadow focus/composed targets. Native input, textarea,
select, inherited/plaintext editing and IME composition suppress hotkeys while preserving
the generic keydown event. Existing modifier suppression remains. This fixes typing being
intercepted after bindGlobalEvents or inside shadow roots. The global binding itself is
owned by events/globalInit.ts, described below.
The instance scope removes the one keyboard subscription and callbacks check destruction
before continuing or emitting success. Ordinary synchronous errors still propagate with
their identity. Callback dispatch intentionally retains historical live-array mutation
and this=art semantics. Own-property registration makes prototype-named keys safe without
changing the public keys prototype. Direct editing of those arrays remains observable.
Run `node --test test/hotkey.test.js test/playback.test.js` and
`yarn test:browser test/browser/hotkey.spec.js`. Browser tests exercise native typing and
play/pause/seek/volume/Escape, iframe and shadow editors, controlled IME and mobile-UA
initialization, and callback destruction. Controlled composition/mobile UA is not physical
keyboard/IME/device certification. Input and scheduling share instance ownership;
the refactor progress record tracks final installed UMD/legacy acceptance.
## Native listeners and global targets (CORE-17)
events/index.ts keeps the compatibility facade with the original bound proxy/hover
methods and destroyEvents Set. events/listener-registry.ts owns native registration,
capture snapshots, array rollback, abort and explicit cleanup. Native callback identity
is preserved: direct removeEventListener with the original callback still works. Null
options keep their historical native defaults. Duplicate callbacks retain native
deduplication; disposal is not reference counted. Native once removes the listener itself,
while its bookkeeping remains until explicit disposal or owner cleanup. Abort removes
both the listener and its owned record. Failed removals warn and remain retryable;
a target that permanently refuses removal cannot be forcibly cleaned up.
events/global-types.ts defines the minimal host, registry and source interfaces.
events/globalInit.ts stages document/window listeners, forwards only from the committed
binding, and retains the old binding if registration fails. A generation counter stops
an interrupted binding from overwriting a newer nested bind; owner destruction prevents
late activation. The original event object, forwarding names, void return and independent
document/window fallback rules remain. Manual Events.destroy still permits later reuse
while the player is alive. events/types.ts composes the typed initializer hosts without
adding runtime fields or changing initializer order.
Run `node --test test/listener-registry.test.js test/global-events.test.js` and
`yarn test:browser test/browser/listener-registry.spec.js test/browser/global-events.spec.js`.
Tests cover actual iframe targets and native once/abort/callback identity; injected
registration/removal failures exercise cleanup without asserting platform failure rates.
## Pointer interaction (CORE-17)
events/clickInit.ts retains synchronous click counting, the inclusive double-click
threshold, live constructor settings and existing desktop/mobile actions. It checks
instance closure after caller callbacks, preventing playback/fullscreen actions after
destruction. input/pointer-focus.ts owns click/contextmenu emitter subscriptions while
preserving isInput's historical INPUT-only meaning and composed-path focus checks.
input/pointer-types.ts keeps host and native registry requirements explicit.
events/hoverInit.ts and moveInit.ts retain original events and class-before-notification
ordering, with guards against stale callbacks. Emitter listeners still use their explicit
ctx argument; unlike hotkey callbacks, they do not acquire this=art automatically.
Public player methods remain non-configurable and are not replaced by these changes.
`test/pointer-events.test.js` verifies boundaries, nesting, callback failures, rejected
playback, destruction and owned focus cleanup. `test/browser/pointer-events.spec.js`
compares old/new events, controlled failure paths and actual trusted mouse input with
real media playback. Android UA coverage exercises branch logic, not a physical device.
## Touch gestures (CORE-17)
events/gestureInit.ts installs the mobile-only listeners; gesture=false still retains
progress touches and isLive skips gesture installation. input/gesture-controller.ts
owns the active touch, source snapshot and orientation, plus end/cancel/lock subscriptions.
input/gesture-direction.ts preserves the two-pixel threshold and diagonal boundaries;
gesture-types.ts describes the minimal host and drag state. Normal video gestures keep
TOUCH_MOVE_RATIO, progress gestures keep their full-width multiplier and rotated gestures
use the vertical axis. Invalid geometry or a cancelled/replaced session cannot resume
the previous drag. Callback errors retain identity; nested gestures supersede interrupted
work, and destruction prevents subsequent seek/bar/notice writes.
control/progress/position.ts now accepts a minimal position host and an optional lifetime
predicate between its bar notification and seek. Existing two-argument desktop callers
retain their default behavior. Global events add document:touchcancel with the original
Event payload; its public declaration is additive. Direct local cancellation also works
when document forwarding has been rebound. Source changes cancel through source identity
rather than depending on delayed media events.
`test/gesture.test.js` covers axes, absolute/relative ordering, reentry, invalid geometry,
source/rotation/lock/finger changes and cleanup. Browser gestures use controlled touch
payloads with actual media and DOM targets. They check requests before first play and
actual seeking after playback initializes the decoder; they do not certify physical
touch delivery, native scrolling arbitration or mobile OS cancellation behavior.
## Input scheduling and Events facade (CORE-17)
events/resizeInit.ts retains debounce coalescing, live RESIZE_TIME, normal auto-size,
aspect-ratio restoration and notice cleanup. It guards later writes after caller reentry
and listens to the owner's screen orientation by event capability rather than onchange
being non-null. events/viewInit.ts owns a leading-only throttle, captures SCROLL_TIME at
installation, reads SCROLL_GAP live and preserves synchronous nested dispatch. Its reset
timer and internal subscriptions are released at destruction. Public throttle/debounce
utilities retain their existing independent behavior.
events/viewport.ts uses the container's current ownerDocument/defaultView, preserving
existing edge and gap arithmetic after iframe adoption. events/updateInit.ts keeps RAF
opt-in, the initial playing emission and one pending owned frame; paused players do not
emit raf, and destruction inside a callback cannot schedule another frame. An explicit
destroy notification still cancels the pending frame while the owner is alive.
events/subscriptions.ts provides typed, guarded, owner-scoped emitter subscriptions;
scheduling-types.ts describes the event maps and minimal scheduling hosts. The TS Events
facade keeps its own destroyEvents/proxy/hover/bindGlobalEvents properties, prototype
methods, bound proxy/hover behavior and scalar/array disposer returns. It declares the
rebinding property without emitting an extra class field during construction.
`test/event-scheduling.test.js` verifies debounce/throttle/RAF ownership and reentry.
`test/browser/event-scheduling.spec.js` compares public facade shape, real animation
frames/playback, orientation event capability, timer cleanup and adopted iframe visibility.
Controlled orientation targets do not certify physical device orientation.
## Display modes (CORE-16, in progress)
player/autoSizeMix.ts, autoHeightMix.ts, aspectRatioMix.ts and flipMix.ts retain the
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+578 -355
View File
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,15 @@
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 interface ProgressPositionHost {
template: { $progress: HTMLElement }
duration: number
isRotate: boolean
top: number
height: number
seek: number | string
emit: (name: 'setBar', kind: 'played', percentage: number, event: ProgressEvent) => unknown
}
export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
const { $progress } = art.template
@@ -15,16 +22,18 @@ export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent)
return { second, time, width, percentage }
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent, active: () => boolean = () => true) {
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
if (active())
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
if (active())
art.seek = second
}
}
@@ -1,65 +0,0 @@
import { includeFromEvent, isMobile, silencePromise } from '../utils'
export default function clickInit(art, events) {
const {
constructor,
template: { $player, $video },
} = art
function onDocumentClick(event) {
if (includeFromEvent(event, $player)) {
art.isInput = event.target.tagName === 'INPUT'
art.isFocus = true
art.emit('focus', event)
}
else {
art.isInput = false
art.isFocus = false
art.emit('blur', event)
}
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
let clickTimes = []
events.proxy($video, 'click', (event) => {
const now = Date.now()
clickTimes.push(now)
const { MOBILE_CLICK_PLAY, DBCLICK_TIME, MOBILE_DBCLICK_PLAY, DBCLICK_FULLSCREEN } = constructor
const clicks = clickTimes.filter(t => now - t <= DBCLICK_TIME)
switch (clicks.length) {
case 1:
art.emit('click', event)
if (isMobile) {
if (!art.isLock && MOBILE_CLICK_PLAY) {
silencePromise(art.toggle())
}
}
else {
silencePromise(art.toggle())
}
clickTimes = clicks
break
case 2:
art.emit('dblclick', event)
if (isMobile) {
if (!art.isLock && MOBILE_DBCLICK_PLAY) {
silencePromise(art.toggle())
}
}
else {
if (DBCLICK_FULLSCREEN) {
art.fullscreen = !art.fullscreen
}
}
clickTimes = []
break
default:
clickTimes = []
}
})
}
@@ -0,0 +1,45 @@
import type { ClickHost, PointerRegistry } from '../input/pointer-types'
import { pointerFocus } from '../input/pointer-focus'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import { silencePromise } from '../utils/error'
export default function clickInit(art: ClickHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { constructor, template: { $video } } = art
pointerFocus(art)
let clickTimes: number[] = []
events.proxy($video, 'click', (event) => {
if (isClosing(art))
return
const now = Date.now()
clickTimes.push(now)
const { MOBILE_CLICK_PLAY, DBCLICK_TIME, MOBILE_DBCLICK_PLAY, DBCLICK_FULLSCREEN } = constructor
const clicks = clickTimes.filter(t => now - t <= DBCLICK_TIME)
switch (clicks.length) {
case 1:
art.emit('click', event)
if (isClosing(art))
return
if (!isMobile || (!art.isLock && MOBILE_CLICK_PLAY))
silencePromise(art.toggle())
clickTimes = clicks
break
case 2:
art.emit('dblclick', event)
if (isClosing(art))
return
if (isMobile) {
if (!art.isLock && MOBILE_DBCLICK_PLAY)
silencePromise(art.toggle())
}
else if (DBCLICK_FULLSCREEN) {
art.fullscreen = !art.fullscreen
}
clickTimes = []
break
default:
clickTimes = []
}
})
}
@@ -1,104 +0,0 @@
import { setCurrentTime } from '../control/progress'
import { clamp, isMobile, secondToTime } from '../utils'
function GetSlideAngle(dx, dy) {
return (Math.atan2(dy, dx) * 180) / Math.PI
}
function GetSlideDirection(startX, startY, endX, endY) {
const dy = startY - endY
const dx = endX - startX
let result = 0
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) {
return result
}
const angle = GetSlideAngle(dx, dy)
if (angle >= -45 && angle < 45) {
result = 4
}
else if (angle >= 45 && angle < 135) {
result = 1
}
else if (angle >= -135 && angle < -45) {
result = 2
}
else if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135)) {
result = 3
}
return result
}
export default function gestureInit(art, events) {
if (isMobile && !art.option.isLive) {
const { $video, $progress } = art.template
let touchTarget = null
let isDragging = false
let startX = 0
let startY = 0
let startTime = 0
const onTouchStart = (event) => {
if (event.touches.length === 1 && !art.isLock) {
if (touchTarget === $progress) {
setCurrentTime(art, event)
}
isDragging = true
const { pageX, pageY } = event.touches[0]
startX = pageX
startY = pageY
startTime = art.currentTime
}
}
const onTouchMove = (event) => {
if (event.touches.length === 1 && isDragging && art.duration) {
const { pageX, pageY } = event.touches[0]
const direction = GetSlideDirection(startX, startY, pageX, pageY)
const isHorizontal = [3, 4].includes(direction)
const isVertical = [1, 2].includes(direction)
const isLegal = (isHorizontal && !art.isRotate) || (isVertical && art.isRotate)
if (isLegal) {
const ratioX = clamp((pageX - startX) / art.width, -1, 1)
const ratioY = clamp((pageY - startY) / art.height, -1, 1)
const ratio = art.isRotate ? ratioY : ratioX
const TOUCH_MOVE_RATIO = touchTarget === $video ? art.constructor.TOUCH_MOVE_RATIO : 1
const currentTime = clamp(startTime + art.duration * ratio * TOUCH_MOVE_RATIO, 0, art.duration)
art.seek = currentTime
art.emit('setBar', 'played', clamp(currentTime / art.duration, 0, 1), event)
art.notice.show = `${secondToTime(currentTime)} / ${secondToTime(art.duration)}`
}
}
}
const onTouchEnd = () => {
if (isDragging) {
startX = 0
startY = 0
startTime = 0
isDragging = false
touchTarget = null
}
}
if (art.option.gesture) {
events.proxy($video, 'touchstart', (event) => {
touchTarget = $video
onTouchStart(event)
})
events.proxy($video, 'touchmove', onTouchMove)
}
events.proxy($progress, 'touchstart', (event) => {
touchTarget = $progress
onTouchStart(event)
})
events.proxy($progress, 'touchmove', onTouchMove)
art.on('document:touchend', onTouchEnd)
}
}
@@ -0,0 +1,19 @@
import type { GestureHost } from '../input/gesture-types'
import type { PointerRegistry } from '../input/pointer-types'
import { gestureController } from '../input/gesture-controller'
import { isMobile } from '../utils/compatibility'
export default function gestureInit(art: GestureHost, events: Pick<PointerRegistry, 'proxy'>): void {
if (!isMobile || art.option.isLive)
return
const { $video, $progress } = art.template
const gesture = gestureController(art)
const bind = (target: EventTarget) => {
events.proxy(target, 'touchstart', event => gesture.start(target, event as TouchEvent))
events.proxy(target, 'touchmove', event => gesture.move(event as TouchEvent))
events.proxy(target, 'touchcancel', gesture.cancel)
}
if (art.option.gesture)
bind($video)
bind($progress)
}
@@ -0,0 +1,17 @@
import type { Disposer, Listener } from './listener-registry'
export interface GlobalEventSource {
document?: Document
window?: Window
}
export interface GlobalEventHost {
template: { $player: HTMLElement }
emit: (name: string, event: Event) => unknown
}
export interface GlobalEventRegistry {
proxy: (target: EventTarget, name: string, listener: Listener) => Disposer
remove: (dispose: Disposer) => void
bindGlobalEvents?: (source?: GlobalEventSource) => void
}
@@ -1,52 +0,0 @@
export default function globalInit(art, events) {
const documentEvents = [
'click',
'mouseup',
'keydown',
'touchend',
'touchmove',
'mousemove',
'pointerup',
'contextmenu',
'pointermove',
'visibilitychange',
'webkitfullscreenchange',
]
const windowEvents = [
'resize',
'scroll',
'orientationchange',
]
const destroyEvents = []
function bindGlobalEvents(source = {}) {
for (let index = 0; index < destroyEvents.length; index++) {
events.remove(destroyEvents[index])
}
destroyEvents.length = 0
const { $player } = art.template
documentEvents.forEach((name) => {
const doc = source.document || $player.ownerDocument || document
const destroy = events.proxy(doc, name, (event) => {
art.emit(`document:${name}`, event)
})
destroyEvents.push(destroy)
})
windowEvents.forEach((name) => {
const win = source.window || $player.ownerDocument?.defaultView || window
const destroy = events.proxy(win, name, (event) => {
art.emit(`window:${name}`, event)
})
destroyEvents.push(destroy)
})
}
bindGlobalEvents()
events.bindGlobalEvents = bindGlobalEvents
}
@@ -0,0 +1,80 @@
import type { GlobalEventHost, GlobalEventRegistry, GlobalEventSource } from './global-types'
import type { Disposer } from './listener-registry'
import { getScope, isClosing } from '../lifecycle/instance'
const documentEvents = [
'click',
'mouseup',
'keydown',
'touchend',
'touchcancel',
'touchmove',
'mousemove',
'pointerup',
'contextmenu',
'pointermove',
'visibilitychange',
'webkitfullscreenchange',
]
const windowEvents = ['resize', 'scroll', 'orientationchange']
interface Binding {
disposers: Disposer[]
}
export default function globalInit(art: GlobalEventHost, events: GlobalEventRegistry): void {
let active: Binding | undefined
let generation = 0
getScope(art).add(() => {
active = undefined
generation++
return undefined
})
const release = (binding: Binding | undefined) => {
if (binding) {
for (const dispose of binding.disposers)
events.remove(dispose)
binding.disposers.length = 0
}
}
function bindGlobalEvents(source: GlobalEventSource = {}): void {
if (isClosing(art))
return
const current = ++generation
const binding: Binding = { disposers: [] }
const cancelled = () => current !== generation || isClosing(art)
try {
const { $player } = art.template
const doc = source.document || $player.ownerDocument || document
const win = source.window || $player.ownerDocument?.defaultView || window
const register = (target: EventTarget, names: string[], prefix: string) => {
for (const name of names) {
if (cancelled())
return
binding.disposers.push(events.proxy(target, name, (event) => {
if (active === binding && !isClosing(art))
art.emit(`${prefix}:${name}`, event)
}))
}
}
register(doc, documentEvents, 'document')
register(win, windowEvents, 'window')
}
catch (error) {
release(binding)
throw error
}
if (cancelled()) {
release(binding)
return
}
const previous = active
active = binding
release(previous)
}
bindGlobalEvents()
events.bindGlobalEvents = bindGlobalEvents
}
@@ -1,17 +0,0 @@
import { addClass, removeClass } from '../utils'
export default function hoverInit(art, events) {
const { $player } = art.template
events.hover(
$player,
(event) => {
addClass($player, 'art-hover')
art.emit('hover', true, event)
},
(event) => {
removeClass($player, 'art-hover')
art.emit('hover', false, event)
},
)
}
@@ -0,0 +1,22 @@
import type { HoverHost, PointerRegistry } from '../input/pointer-types'
import { isClosing } from '../lifecycle/instance'
import { addClass, removeClass } from '../utils/dom'
export default function hoverInit(art: HoverHost, events: Pick<PointerRegistry, 'hover'>): void {
const { $player } = art.template
events.hover(
$player,
(event) => {
if (isClosing(art))
return
addClass($player, 'art-hover')
art.emit('hover', true, event)
},
(event) => {
if (isClosing(art))
return
removeClass($player, 'art-hover')
art.emit('hover', false, event)
},
)
}
-80
View File
@@ -1,80 +0,0 @@
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
import hoverInit from './hoverInit'
import moveInit from './moveInit'
import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
const scopes = new WeakMap()
export default class Events {
constructor(art) {
this.destroyEvents = new Set()
const scope = getScope(art)
scopes.set(this, scope)
scope.add(() => this.destroy())
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
clickInit(art, this)
hoverInit(art, this)
moveInit(art, this)
resizeInit(art, this)
gestureInit(art, this)
viewInit(art, this)
globalInit(art, this)
updateInit(art, this)
}
proxy(target, name, callback, option = {}) {
if (Array.isArray(name)) {
return name.map(item => this.proxy(target, item, callback, option))
}
if (scopes.get(this).closed)
return () => {}
target.addEventListener(name, callback, option)
const destroy = () => target.removeEventListener(name, callback, option)
this.destroyEvents.add(destroy)
return destroy
}
hover(target, mouseenter, mouseleave) {
if (mouseenter) {
this.proxy(target, 'mouseenter', mouseenter)
}
if (mouseleave) {
this.proxy(target, 'mouseleave', mouseleave)
}
}
remove(destroyEvent) {
if (this.destroyEvents.has(destroyEvent)) {
try {
destroyEvent()
}
catch (error) {
console.warn('Failed to remove event listener:', error)
}
finally {
this.destroyEvents.delete(destroyEvent)
}
}
}
destroy() {
for (const destroyEvent of this.destroyEvents) {
try {
destroyEvent()
}
catch (error) {
console.warn('Failed to destroy event listener:', error)
}
}
this.destroyEvents.clear()
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { GlobalEventSource } from './global-types'
import type { Disposer, Listener } from './listener-registry'
import type { EventsHost } from './types'
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
import hoverInit from './hoverInit'
import { destroyListeners, ownListeners, proxyListener, removeListener } from './listener-registry'
import moveInit from './moveInit'
import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
export default class Events {
declare destroyEvents: Set<Disposer>
declare bindGlobalEvents: (source?: GlobalEventSource) => void
constructor(art: EventsHost) {
this.destroyEvents = new Set()
ownListeners(this, art)
getScope(art).add(() => {
this.destroy()
})
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
clickInit(art, this)
hoverInit(art, this)
moveInit(art, this)
resizeInit(art, this)
gestureInit(art, this)
viewInit(art)
globalInit(art, this)
updateInit(art)
}
proxy(target: EventTarget, name: string, callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer
proxy(target: EventTarget, name: string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer[]
proxy(target: EventTarget, name: string | string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer | Disposer[]
proxy(target: EventTarget, name: string | string[], callback: Listener, option: boolean | AddEventListenerOptions | null = {}): Disposer | Disposer[] {
return Array.isArray(name) ? proxyListener(this, target, name, callback, option) : proxyListener(this, target, name, callback, option)
}
hover(target: EventTarget, mouseenter?: (event: Event) => unknown, mouseleave?: (event: Event) => unknown): void {
if (mouseenter)
this.proxy(target, 'mouseenter', mouseenter)
if (mouseleave)
this.proxy(target, 'mouseleave', mouseleave)
}
remove(dispose: Disposer): void {
removeListener(this, dispose)
}
destroy(): void {
destroyListeners(this)
}
}
@@ -0,0 +1,135 @@
import type ResourceScope from '../lifecycle/scope'
import { getScope } from '../lifecycle/instance'
export type Disposer = () => void
export type Listener = EventListenerOrEventListenerObject | null
export interface ListenerRegistry {
destroyEvents: Set<Disposer>
}
interface State {
scope: ResourceScope
cleaning: boolean
}
const states = new WeakMap<ListenerRegistry, State>()
export function ownListeners(registry: ListenerRegistry, art: object): void {
states.set(registry, { scope: getScope(art), cleaning: false })
}
function stateOf(registry: ListenerRegistry): State {
const state = states.get(registry)
if (!state)
throw new Error('ArtPlayer event registry has not been initialized')
return state
}
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer[]
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string, callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string | string[], callback: Listener, option: boolean | AddEventListenerOptions | null = {}): Disposer | Disposer[] {
if (Array.isArray(names)) {
const created: Disposer[] = []
try {
return names.map((name) => {
const dispose = proxyListener(registry, target, name, callback, option)
created.push(dispose)
return dispose
})
}
catch (error) {
for (const dispose of created.reverse()) {
try {
dispose()
}
catch (cleanupError) {
console.warn('Failed to roll back event listener:', cleanupError)
}
}
throw error
}
}
const name = names
const state = stateOf(registry)
if (state.scope.closed || state.cleaning)
return () => {}
// Snapshot native dictionary values, especially capture, without wrapping the callback.
const options = typeof option === 'boolean'
? option
: {
capture: Boolean(option?.capture),
once: Boolean(option?.once),
passive: option?.passive,
signal: option?.signal,
}
const capture = typeof options === 'boolean' ? options : options.capture
const signal = typeof options === 'boolean' ? undefined : options.signal
if (state.scope.closed || state.cleaning || signal?.aborted)
return () => {}
let active = true
function clean(force = false): void {
if (!active && !force)
return
target.removeEventListener(name, callback, capture)
signal?.removeEventListener('abort', dispose)
active = false
registry.destroyEvents.delete(dispose)
}
function dispose(): void {
clean()
}
registry.destroyEvents.add(dispose)
try {
target.addEventListener(name, callback, options)
// A custom target may synchronously destroy the player before adding the listener.
if (!active || state.scope.closed || state.cleaning)
clean(true)
else if (signal?.aborted)
dispose()
else
signal?.addEventListener('abort', dispose, { once: true })
}
catch (error) {
try {
clean(true)
}
catch (cleanupError) {
console.warn('Failed to roll back event listener:', cleanupError)
}
throw error
}
return dispose
}
export function removeListener(registry: ListenerRegistry, dispose: Disposer): void {
if (!registry.destroyEvents.has(dispose))
return
try {
dispose()
registry.destroyEvents.delete(dispose)
}
catch (error) {
// Retain a failed removal so a later remove/destroy can retry it.
console.warn('Failed to remove event listener:', error)
}
}
export function destroyListeners(registry: ListenerRegistry): void {
const state = stateOf(registry)
if (state.cleaning)
return
state.cleaning = true
try {
for (const dispose of registry.destroyEvents) {
try {
dispose()
registry.destroyEvents.delete(dispose)
}
catch (error) {
console.warn('Failed to destroy event listener:', error)
}
}
}
finally {
state.cleaning = false
}
}
@@ -1,7 +0,0 @@
export default function moveInit(art, events) {
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
art.emit('mousemove', event)
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { MoveHost, PointerRegistry } from '../input/pointer-types'
import { isClosing } from '../lifecycle/instance'
export default function moveInit(art: MoveHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
if (!isClosing(art))
art.emit('mousemove', event)
})
}
@@ -1,29 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
export default function resizeInit(art, events) {
const { option, constructor } = art
art.on('resize', () => {
const { aspectRatio, notice } = art
if (art.state === 'standard' && option.autoSize) {
art.autoSize()
}
art.aspectRatio = aspectRatio
notice.show = ''
})
const scope = getScope(art)
let cancel = () => {}
const resizeFn = () => {
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
art.on('window:orientationchange', () => resizeFn())
art.on('window:resize', () => resizeFn())
if (screen && screen.orientation && screen.orientation.onchange) {
events.proxy(screen.orientation, 'change', () => resizeFn())
}
}
@@ -0,0 +1,35 @@
import type { PointerRegistry } from '../input/pointer-types'
import type { ResizeEvents, ResizeHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
export default function resizeInit(art: ResizeHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { option, constructor } = art
const on = eventSubscriptions<ResizeEvents>(art)
on('resize', () => {
const { aspectRatio, notice } = art
if (art.state === 'standard' && option.autoSize)
art.autoSize()
if (isClosing(art))
return
art.aspectRatio = aspectRatio
if (!isClosing(art))
notice.show = ''
})
const scope = getScope(art)
let cancel = () => {}
const resize = () => {
if (isClosing(art))
return
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
on('window:orientationchange', resize)
on('window:resize', resize)
const orientation = art.template.$player.ownerDocument.defaultView?.screen?.orientation
if (typeof orientation?.addEventListener === 'function')
events.proxy(orientation, 'change', resize)
}
@@ -0,0 +1,34 @@
import type { SubscriptionHost } from '../component/resources'
export interface ResizeEvents {
'resize': []
'window:resize': [Event]
'window:orientationchange': [Event]
}
export interface ResizeHost extends SubscriptionHost<ResizeEvents> {
template: { $player: HTMLElement }
option: { autoSize: boolean }
constructor: { RESIZE_TIME: number }
aspectRatio: string
state: string
autoSize: () => unknown
notice: { show: string }
emit: (name: 'resize') => unknown
}
export interface ViewEvents { 'window:scroll': [Event], 'view': [boolean] }
export interface ViewHost extends SubscriptionHost<ViewEvents> {
template: { $container: HTMLElement }
option: { autoMini: boolean }
constructor: { SCROLL_GAP: number, SCROLL_TIME: number }
mini: boolean
emit: (name: 'view', visible: boolean) => unknown
}
export interface RafEvents { destroy: [] }
export interface RafHost extends SubscriptionHost<RafEvents> {
constructor: { USE_RAF: boolean }
playing: boolean
emit: (name: 'raf') => unknown
}
@@ -0,0 +1,17 @@
import type { SubscriptionHost } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
export function eventSubscriptions<Events extends { [Name in keyof Events]: readonly unknown[] }>(art: SubscriptionHost<Events>) {
return <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown): void => {
if (isClosing(art))
return
const listener = (...args: [...Events[Name]]) => {
if (!isClosing(art))
return callback(...args)
}
art.on(name, listener)
getScope(art).add(() => {
art.off(name, listener)
})
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { GestureHost } from '../input/gesture-types'
import type { ClickHost, HoverHost, MoveHost } from '../input/pointer-types'
import type { GlobalEventHost } from './global-types'
import type { RafHost, ResizeHost, ViewHost } from './scheduling-types'
export type EventsHost = ClickHost & HoverHost & MoveHost & GestureHost & GlobalEventHost & RafHost & ResizeHost & ViewHost
@@ -1,23 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
export default function updateInit(art) {
if (art.constructor.USE_RAF) {
const scope = getScope(art)
let cancel = () => {};
(function update() {
if (art.playing) {
art.emit('raf')
}
if (!art.isDestroy) {
cancel = animationFrame(scope, update)
}
})()
art.on('destroy', () => {
cancel()
})
}
}
@@ -0,0 +1,21 @@
import type { RafEvents, RafHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
export default function updateInit(art: RafHost): void {
if (!art.constructor.USE_RAF)
return
const scope = getScope(art)
let cancel = () => {}
const update = () => {
if (isClosing(art))
return
if (art.playing)
art.emit('raf')
if (!isClosing(art))
cancel = animationFrame(scope, update)
}
update()
eventSubscriptions<RafEvents>(art)('destroy', () => cancel())
}
-21
View File
@@ -1,21 +0,0 @@
import { isInViewport, throttle } from '../utils'
export default function viewInit(art) {
const {
option,
constructor,
template: { $container },
} = art
const scrollFn = throttle(() => {
art.emit('view', isInViewport($container, constructor.SCROLL_GAP))
}, constructor.SCROLL_TIME)
art.on('window:scroll', () => scrollFn())
art.on('view', (state) => {
if (option.autoMini) {
art.mini = !state
}
})
}
+29
View File
@@ -0,0 +1,29 @@
import type { ViewEvents, ViewHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
import { inViewport } from './viewport'
export default function viewInit(art: ViewHost): void {
const { option, constructor, template: { $container } } = art
const delay = constructor.SCROLL_TIME
const scope = getScope(art)
const on = eventSubscriptions<ViewEvents>(art)
let waiting = false
on('window:scroll', () => {
if (waiting)
return
art.emit('view', inViewport($container, constructor.SCROLL_GAP))
if (isClosing(art))
return
// Keep the leading-only throttle and its historical synchronous reentry timing.
waiting = true
timeout(scope, () => {
waiting = false
}, delay)
})
on('view', (visible) => {
if (option.autoMini)
art.mini = !visible
})
}
+11
View File
@@ -0,0 +1,11 @@
export function inViewport(element: HTMLElement, offset: number): boolean {
const rect = element.getBoundingClientRect()
const document = element.ownerDocument
const window = document.defaultView
const height = window?.innerHeight || document.documentElement.clientHeight
const width = window?.innerWidth || document.documentElement.clientWidth
// Retain the historical edge/gap calculations while using the actual owner viewport.
const vertical = rect.top - offset <= height && rect.top + rect.height + offset >= 0
const horizontal = rect.left - offset <= width + offset && rect.left + rect.width + offset >= 0
return vertical && horizontal
}
-96
View File
@@ -1,96 +0,0 @@
import { isMobile, silencePromise } from './utils'
export default class Hotkey {
constructor(art) {
this.art = art
this.keys = {}
if (!isMobile) {
this.init()
}
}
init() {
const { constructor } = this.art
if (this.art.option.hotkey) {
this.add('Escape', () => {
if (this.art.fullscreenWeb) {
this.art.fullscreenWeb = false
}
})
this.add('Space', () => {
silencePromise(this.art.toggle())
})
this.add('ArrowLeft', () => {
this.art.backward = constructor.SEEK_STEP
})
this.add('ArrowUp', () => {
this.art.volume += constructor.VOLUME_STEP
})
this.add('ArrowRight', () => {
this.art.forward = constructor.SEEK_STEP
})
this.add('ArrowDown', () => {
this.art.volume -= constructor.VOLUME_STEP
})
}
this.art.on('document:keydown', (event) => {
if (this.art.isFocus) {
const tag = document.activeElement.tagName.toUpperCase()
const editable = document.activeElement.getAttribute('contenteditable')
if (
tag !== 'INPUT'
&& tag !== 'TEXTAREA'
&& editable !== ''
&& editable !== 'true'
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
&& !event.shiftKey
) {
const events = this.keys[event.code]
if (events) {
event.preventDefault()
for (let index = 0; index < events.length; index++) {
events[index].call(this.art, event)
}
this.art.emit('hotkey', event)
}
}
}
this.art.emit('keydown', event)
})
}
add(key, event) {
if (this.keys[key]) {
if (!this.keys[key].includes(event)) {
this.keys[key].push(event)
}
}
else {
this.keys[key] = [event]
}
return this
}
remove(key, event) {
if (this.keys[key]) {
const index = this.keys[key].indexOf(event)
if (index !== -1) {
this.keys[key].splice(index, 1)
}
if (this.keys[key].length === 0) {
delete this.keys[key]
}
}
return this
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { HotkeyCallback, HotkeyHost } from './input/hotkey-types'
import { defaultHotkeys } from './input/hotkey-defaults'
import { acceptsHotkey } from './input/keyboard-focus'
import { getScope, isClosing } from './lifecycle/instance'
import { isMobile } from './utils/compatibility'
interface State {
defaults?: () => void
subscribed: boolean
}
const states = new WeakMap<object, State>()
export default class Hotkey<Host extends HotkeyHost> {
declare art: Host
declare keys: Record<string, HotkeyCallback<Host>[]>
constructor(art: Host) {
this.art = art
this.keys = {}
states.set(this, { subscribed: false })
if (!isMobile)
this.init()
}
init(): void {
const art = this.art
if (isClosing(art))
return
const state = states.get(this)!
if (art.option.hotkey) {
if (!state.defaults) {
const callbacks = defaultHotkeys(art)
state.defaults = () => {
for (const key of Object.keys(callbacks))
this.add(key, callbacks[key]!)
}
}
state.defaults()
}
if (state.subscribed)
return
state.subscribed = true
const onKeydown = (event: KeyboardEvent) => {
if (isClosing(art))
return
if (art.isFocus && acceptsHotkey(event, art.template.$player.ownerDocument)) {
const callbacks = Object.prototype.hasOwnProperty.call(this.keys, event.code) ? this.keys[event.code] : undefined
if (callbacks) {
event.preventDefault()
// Keep the historical live-array mutation and callback receiver contracts.
for (let index = 0; index < callbacks.length; index++) {
if (isClosing(art))
return
callbacks[index]!.call(art, event)
}
if (isClosing(art))
return
art.emit('hotkey', event)
}
}
if (!isClosing(art))
art.emit('keydown', event)
}
art.on('document:keydown', onKeydown)
getScope(art).add(() => {
state.subscribed = false
art.off('document:keydown', onKeydown)
})
}
add(key: string, callback: HotkeyCallback<Host>): this {
const existing = Object.prototype.hasOwnProperty.call(this.keys, key) ? this.keys[key] : undefined
if (existing) {
if (!existing.includes(callback))
existing.push(callback)
}
else {
Object.defineProperty(this.keys, key, { value: [callback], enumerable: true, configurable: true, writable: true })
}
return this
}
remove(key: string, callback: HotkeyCallback<Host>): this {
const existing = Object.prototype.hasOwnProperty.call(this.keys, key) ? this.keys[key] : undefined
if (existing) {
const index = existing.indexOf(callback)
if (index !== -1)
existing.splice(index, 1)
if (existing.length === 0)
delete this.keys[key]
}
return this
}
}
@@ -0,0 +1,94 @@
import type { Drag, GestureHost } from './gesture-types'
import { setCurrentTime } from '../control/progress/position'
import { getScope, isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { clamp, secondToTime } from '../utils'
import { slideDirection } from './gesture-direction'
export function gestureController(art: GestureHost) {
let drag: Drag | undefined
const cancel = () => {
drag = undefined
}
const active = (current: Drag) => drag === current && !isClosing(art) && !art.isLock
&& !art.option.isLive && art.isRotate === current.rotated && current.sourceActive()
const onLock = (locked: boolean) => {
if (locked)
cancel()
}
art.on('document:touchend', cancel)
art.on('document:touchcancel', cancel)
art.on('lock', onLock)
getScope(art).add(() => {
cancel()
art.off('document:touchend', cancel)
art.off('document:touchcancel', cancel)
art.off('lock', onLock)
})
const start = (target: EventTarget, event: TouchEvent) => {
cancel()
const touch = event.touches[0]
if (isClosing(art) || art.isLock || art.option.isLive || event.touches.length !== 1 || !touch)
return
if (![touch.pageX, touch.pageY, touch.clientX, touch.clientY, art.currentTime].every(Number.isFinite))
return
const current: Drag = {
target,
identifier: touch.identifier,
x: touch.pageX,
y: touch.pageY,
time: art.currentTime,
rotated: art.isRotate,
sourceActive: captureSource(art),
}
drag = current
if (target === art.template.$progress) {
const size = art.isRotate ? art.height : art.template.$progress.clientWidth
if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(art.duration) || art.duration <= 0) {
cancel()
return
}
try {
setCurrentTime(art, event, () => active(current))
}
catch (error) {
if (drag === current)
cancel()
throw error
}
if (active(current))
current.time = art.currentTime
}
}
const move = (event: TouchEvent) => {
const current = drag
const touch = event.touches[0]
if (!current)
return
if (!active(current) || event.touches.length !== 1 || !touch || touch.identifier !== current.identifier) {
cancel()
return
}
const size = current.rotated ? art.height : art.width
const multiplier = current.target === art.template.$video ? art.constructor.TOUCH_MOVE_RATIO : 1
if (![touch.pageX, touch.pageY, size, art.duration, multiplier].every(Number.isFinite) || size <= 0 || art.duration <= 0) {
cancel()
return
}
const direction = slideDirection(current.x, current.y, touch.pageX, touch.pageY)
if (!(current.rotated ? direction === 1 || direction === 2 : direction === 3 || direction === 4))
return
const distance = current.rotated ? touch.pageY - current.y : touch.pageX - current.x
const ratio = clamp(distance / size, -1, 1)
const time = clamp(current.time + art.duration * ratio * multiplier, 0, art.duration)
art.seek = time
if (!active(current))
return
art.emit('setBar', 'played', clamp(time / art.duration, 0, 1), event)
if (active(current))
art.notice.show = `${secondToTime(time)} / ${secondToTime(art.duration)}`
}
return { start, move, cancel }
}
@@ -0,0 +1,16 @@
export function slideDirection(startX: number, startY: number, endX: number, endY: number): 0 | 1 | 2 | 3 | 4 {
const dy = startY - endY
const dx = endX - startX
if (Math.abs(dx) < 2 && Math.abs(dy) < 2)
return 0
const angle = Math.atan2(dy, dx) * 180 / Math.PI
if (angle >= -45 && angle < 45)
return 4
if (angle >= 45 && angle < 135)
return 1
if (angle >= -135 && angle < -45)
return 2
if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135))
return 3
return 0
}
@@ -0,0 +1,26 @@
import type { SubscriptionHost } from '../component/resources'
import type { ProgressPositionHost } from '../control/progress/position'
export interface GestureHost extends ProgressPositionHost, SubscriptionHost<{
'document:touchend': [Event]
'document:touchcancel': [Event]
'lock': [boolean]
}> {
template: { $video: EventTarget, $progress: HTMLElement }
option: { isLive: boolean, gesture: boolean }
constructor: { TOUCH_MOVE_RATIO: number }
isLock: boolean
width: number
currentTime: number
notice: { show: string }
}
export interface Drag {
target: EventTarget
identifier: number
x: number
y: number
time: number
rotated: boolean
sourceActive: () => boolean
}
@@ -0,0 +1,27 @@
import type { HotkeyCallback, HotkeyHost } from './hotkey-types'
import { silencePromise } from '../utils/error'
export function defaultHotkeys<Host extends HotkeyHost>(art: Host): Record<string, HotkeyCallback<Host>> {
const { constructor } = art
return {
Escape: () => {
if (art.fullscreenWeb)
art.fullscreenWeb = false
},
Space: () => {
silencePromise(art.toggle())
},
ArrowLeft: () => {
art.backward = constructor.SEEK_STEP
},
ArrowUp: () => {
art.volume += constructor.VOLUME_STEP
},
ArrowRight: () => {
art.forward = constructor.SEEK_STEP
},
ArrowDown: () => {
art.volume -= constructor.VOLUME_STEP
},
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface HotkeyHost extends SubscriptionHost<{ 'document:keydown': [KeyboardEvent] }> {
constructor: { SEEK_STEP: number, VOLUME_STEP: number }
option: { hotkey?: boolean }
template: { $player: HTMLElement }
isFocus: boolean
fullscreenWeb: boolean
backward: number
forward: number
volume: number
toggle: () => unknown
emit: (name: 'hotkey' | 'keydown', event: KeyboardEvent) => unknown
}
export type HotkeyCallback<Host> = (this: Host, event: KeyboardEvent) => unknown
@@ -0,0 +1,30 @@
function asElement(target: EventTarget | null | undefined): HTMLElement | undefined {
if (target && (target as Node).nodeType === 1)
return target as HTMLElement
}
function eventDocument(event: KeyboardEvent, fallback: Document): Document {
const target = event.target as Node | null
return target?.nodeType === 9 ? target as Document : target?.ownerDocument || event.view?.document || fallback
}
function editable(target: HTMLElement): boolean {
const tag = target.tagName.toUpperCase()
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
return true
return target.isContentEditable
}
export function acceptsHotkey(event: KeyboardEvent, fallback: Document): boolean {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing || event.keyCode === 229)
return false
const doc = eventDocument(event, fallback)
let active = asElement(doc.activeElement)
while (active?.shadowRoot?.activeElement)
active = asElement(active.shadowRoot.activeElement)
if (active && editable(active))
return false
// The composed path exposes an editable descendant behind a retargeted shadow host.
const first = asElement(event.composedPath?.()[0]) || asElement(event.target)
return !first || !editable(first)
}
@@ -0,0 +1,21 @@
import type { FocusHost } from './pointer-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function pointerFocus(art: FocusHost): void {
const { $player } = art.template
const onDocumentClick = (event: Event) => {
if (isClosing(art))
return
const inside = includeFromEvent(event, $player)
art.isInput = inside && (event.target as Element | null)?.tagName === 'INPUT'
art.isFocus = inside
art.emit(inside ? 'focus' : 'blur', event)
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
getScope(art).add(() => {
art.off('document:click', onDocumentClick)
art.off('document:contextmenu', onDocumentClick)
})
}
@@ -0,0 +1,39 @@
import type { SubscriptionHost } from '../component/resources'
import type { Disposer, Listener } from '../events/listener-registry'
export interface PointerRegistry {
proxy: (target: EventTarget, name: string, callback: Listener) => Disposer
hover: (target: EventTarget, enter?: (event: Event) => unknown, leave?: (event: Event) => unknown) => void
}
export interface FocusHost extends SubscriptionHost<{ 'document:click': [Event], 'document:contextmenu': [Event] }> {
template: { $player: HTMLElement }
isInput: boolean
isFocus: boolean
emit: (name: 'focus' | 'blur', event: Event) => unknown
}
export interface ClickHost extends FocusHost {
constructor: {
MOBILE_CLICK_PLAY: boolean
MOBILE_DBCLICK_PLAY: boolean
DBCLICK_TIME: number
DBCLICK_FULLSCREEN: boolean
}
template: { $player: HTMLElement, $video: EventTarget }
isLock: boolean
fullscreen: boolean
toggle: () => unknown
emit: (name: 'focus' | 'blur' | 'click' | 'dblclick', event: Event) => unknown
}
export interface HoverHost {
template: { $player: HTMLElement }
emit: (name: 'hover', state: boolean, event: Event) => unknown
}
export interface MoveHost {
template: { $player: HTMLElement }
emit: (name: 'mousemove', event: Event) => unknown
}
+1
View File
@@ -14,6 +14,7 @@ export interface Events {
'document:mouseup': [event: Event]
'document:keydown': [event: Event]
'document:touchend': [event: Event]
'document:touchcancel': [event: Event]
'document:touchmove': [event: Event]
'document:mousemove': [event: Event]
'document:pointerup': [event: Event]