mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 06:31:20 -08:00
refactor(core): CORE-19 type capture and preserve progress lifetimes
This commit is contained in:
1 parent
98a1800032
commit
ccf77c4ed2
49 files changed
+31570
-681
No files matched your search
@@ -4,6 +4,101 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
|
||||
The production entry is still `src/index.js`; this document marks actual migrated
|
||||
boundaries rather than describing the entire core as TypeScript.
|
||||
|
||||
## Screenshot capture (CORE-19)
|
||||
|
||||
`src/player/screenshotMix.ts` installs the existing immutable getDataURL,
|
||||
getBlobUrl and screenshot methods. `src/capture/frame.ts` owns synchronous frame
|
||||
drawing and the asynchronous canvas Blob callback boundary. A capture still draws
|
||||
before returning its Promise; moving drawing behind await would select a later
|
||||
video frame. PNG format, extracted-method binding, default filename timing and
|
||||
download-before-event ordering remain unchanged for live operations.
|
||||
|
||||
The public getBlobUrl caller owns URL.revokeObjectURL. Player destruction must not
|
||||
invalidate a returned URL that a consumer still uses. Asynchronous null Blob or
|
||||
URL allocation failure now rejects the capture Promise instead of escaping the
|
||||
callback and leaving it pending. Notice setter failures also reject. Destruction
|
||||
and source replacement suppress stale notices and screenshot download/event
|
||||
effects, while an already captured data string or Blob URL still settles normally.
|
||||
The toolbar consumes its internal rejection after the capture reports a notice;
|
||||
public calls continue to reject for callers to handle.
|
||||
|
||||
Use test/screenshot.test.js, test/types/screenshot.ts and the browser screenshot
|
||||
spec for capture changes. Browser checks decode real video frames, inspect PNG
|
||||
pixels, and use a second hostname without CORS headers for native SecurityError.
|
||||
Installed-package acceptance is recorded in refactor/changes/ for this task.
|
||||
|
||||
## Thumbnail previews and image loading (CORE-19)
|
||||
|
||||
`src/player/thumbnailsMix.ts` preserves the thumbnails property, configuration
|
||||
object identity, live restrictions and control DOM. It owns one image cache scope
|
||||
per configuration, links it to the current thumbnail control and removes its setBar
|
||||
subscription on destruction. Replacement, control removal and destruction cancel
|
||||
loading; obsolete completions cannot render. Loading failures release the pending
|
||||
state so a later interaction may retry. Failures are reported with console.warn
|
||||
instead of escaping an internal event callback as an unhandled rejection.
|
||||
|
||||
The latest hover position wins while an image is pending. A source change prevents
|
||||
rendering old hover coordinates but does not invalidate an unchanged sprite cache;
|
||||
a new interaction may use that image. Each style write checks the active control,
|
||||
configuration and source so reentrant DOM callbacks stop the remaining writes.
|
||||
|
||||
`src/thumbnails/layout.ts` contains the sprite and preview geometry. Dimensions,
|
||||
scale fallback, edge placement and excluded progress endpoints remain unchanged.
|
||||
The old row/cell calculation was incorrect at exact column multiples and produced
|
||||
invalid CSS at cell zero. The corrected zero-based crop matches
|
||||
artplayer-tool-thumbnail's x/index-modulo-column and y/floor-index-over-column
|
||||
generation. Tests explicitly distinguish this display fix from published behavior.
|
||||
|
||||
`src/image/load.ts` implements both image consumers without duplicating decoding:
|
||||
public utils.loadImg still returns Promise<HTMLImageElement>, and the caller owns
|
||||
the src Blob URL of a scaled result. The internal loadThumbnailImage receives a
|
||||
ResourceScope; cancellation fulfills with undefined, and its generated URLs remain
|
||||
owned until replacement/removal/destroy. No caller-supplied image URL is revoked.
|
||||
Every request clears load/error handlers; failed scaled decoding revokes its Blob.
|
||||
Null canvas encoding and asynchronous allocation errors reject public calls. The
|
||||
public helper is re-exported through utils/dom.js to keep the existing utils shape;
|
||||
loadThumbnailImage is not added to the public utils barrel.
|
||||
|
||||
Use test/thumbnails.test.js and test/types/thumbnails.ts for state, cancellation,
|
||||
reentry and API types. The browser thumbnails spec uses the numbered
|
||||
test/browser/media/thumbnail-grid.svg for crop checks and actual image decoding.
|
||||
Transient retry uses a no-store 503 response; browser caching of malformed successful
|
||||
responses is not claimed to be controlled by the player. Source-built checks are
|
||||
intermediate evidence; use the task's final tarball/browser evidence for acceptance.
|
||||
|
||||
## Progress and quality restoration (CORE-19)
|
||||
|
||||
control/progress/interactions.ts captures the control entry, source and action for
|
||||
each click or drag. Geometry and setBar callbacks can synchronously remove the
|
||||
entry, destroy the player, start a new source or start a newer action. Check those
|
||||
boundaries before emitting or seeking. A source change ends the old drag; a new
|
||||
mousedown can start a new one. position.ts retains endpoint clamping and the live
|
||||
progress event before seek, with an optional internal activity predicate.
|
||||
|
||||
source/restore-position.ts owns the paused quality position restoration. Native
|
||||
canplay may arrive while seeking; switch.ts waits for seek completion, restores
|
||||
rate once and checks the accepted, clamped position. A demonstrated native seek
|
||||
miss receives at most one correction, allowing 0.05 seconds for media clock
|
||||
rounding. It does not loop until success. The seeked listener remains until the
|
||||
correction finishes or the source operation is cancelled. Explicit public seek
|
||||
supersedes automatic position restoration; reentrant completion cannot resume
|
||||
twice. media/position-revision.ts also tracks legal public currentTime writes;
|
||||
a consumer assigning currentTime directly during loading supersedes the automatic
|
||||
restoration, including writes before metadata. Invalid NaN assignments remain no-ops.
|
||||
Source cancellation still settles pending work and removes all listeners.
|
||||
Use test/source.test.js, test/progress.test.js and the browser source and
|
||||
progress-quality specs. Keep native media events in evidence when changing this
|
||||
sequence; the original rate-interruption hypothesis was not sufficient to explain
|
||||
the observed first-seek miss.
|
||||
|
||||
urlMix.ts receives media URLs from consumers/adapters and does not revoke them on
|
||||
replacement. Their creator owns release, including Blob media sources. Screenshot
|
||||
and public scaled-image URLs similarly belong to callers; internal thumbnail and
|
||||
subtitle URLs have explicit owned scopes. Real Blob-video decoding is separately
|
||||
capability-tested: this Windows WebKit runtime rejects the local MP4 and WebM Blob
|
||||
samples even without ArtPlayer. Do not turn URL readability checks into claims of
|
||||
native decoding or Apple-device support; see refactor/environment-matrix.md.
|
||||
|
||||
## Builtins and prompts (CORE-18)
|
||||
|
||||
`src/notice.ts` owns the notice timer and DOM visibility. Its minimal host, private
|
||||
@@ -1001,7 +1096,7 @@ continuations. Direct assignment failures are reported with console.warn; switch
|
||||
callers receive the original rejection. `playMix.ts` captures the current source:
|
||||
its native result/rejection is preserved, while obsolete notice/event/mutex effects
|
||||
are suppressed. CORE-10 migrates playback properties and CORE-11 owns reconnect
|
||||
generations; object URL ownership remains CORE-19 work.
|
||||
generations; CORE-19 removes revocation of caller-owned media URLs on replacement.
|
||||
|
||||
`test/source.test.js` checks cancellation, reentry, synchronous events and cleanup;
|
||||
`test/browser/source.spec.js` compares published/candidate real-media switching and
|
||||
|
||||
Vendored
+1
-1
@@ -67,4 +67,4 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
|
||||
+1
-1
@@ -67,4 +67,4 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
|
||||
+467
-223
@@ -496,6 +496,132 @@ const isIOS = /iPad|iPhone|iPod/i.test(userAgent) && !window.MSStream;
|
||||
const isIOS13 = isIOS || userAgent.includes("Macintosh") && navigator.maxTouchPoints >= 1;
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || isIOS13;
|
||||
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
||||
function requestImage(url, scale, owner) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = owner ? owner.child() : new ResourceScope();
|
||||
let complete = false;
|
||||
let releaseBlob = () => {
|
||||
};
|
||||
function fail(error2) {
|
||||
if (complete)
|
||||
return;
|
||||
complete = true;
|
||||
try {
|
||||
request.dispose();
|
||||
} catch (cleanupError) {
|
||||
console.warn("Additional image cleanup failure:", cleanupError);
|
||||
}
|
||||
try {
|
||||
releaseBlob();
|
||||
} catch (cleanupError) {
|
||||
console.warn("Additional image cleanup failure:", cleanupError);
|
||||
}
|
||||
reject(error2);
|
||||
}
|
||||
function finish2(image) {
|
||||
if (complete || request.closed)
|
||||
return;
|
||||
complete = true;
|
||||
try {
|
||||
request.dispose();
|
||||
resolve(image);
|
||||
} catch (error2) {
|
||||
try {
|
||||
releaseBlob();
|
||||
} finally {
|
||||
reject(error2);
|
||||
}
|
||||
}
|
||||
}
|
||||
request.add(() => {
|
||||
if (!complete) {
|
||||
complete = true;
|
||||
try {
|
||||
releaseBlob();
|
||||
} finally {
|
||||
resolve(void 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (request.closed)
|
||||
return;
|
||||
function watch(image, loaded) {
|
||||
image.onload = () => {
|
||||
if (!complete && !request.closed) {
|
||||
try {
|
||||
loaded();
|
||||
} catch (error2) {
|
||||
fail(error2);
|
||||
}
|
||||
}
|
||||
};
|
||||
image.onerror = () => fail(new Error(`Image load failed: ${url}`));
|
||||
request.add(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
if (!complete)
|
||||
image.removeAttribute("src");
|
||||
});
|
||||
}
|
||||
try {
|
||||
const image = new Image();
|
||||
watch(image, () => {
|
||||
if (!scale || scale === 1) {
|
||||
finish2(image);
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
canvas.width = image.width * scale;
|
||||
canvas.height = image.height * scale;
|
||||
if (!context)
|
||||
throw new TypeError("Canvas 2D context is unavailable");
|
||||
if (complete || request.closed)
|
||||
return;
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
if (complete || request.closed)
|
||||
return;
|
||||
canvas.toBlob((blob) => {
|
||||
if (complete || request.closed)
|
||||
return;
|
||||
try {
|
||||
if (!blob)
|
||||
throw new Error("Unable to encode thumbnail image");
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
let revoked = false;
|
||||
const revoke = () => {
|
||||
if (!revoked) {
|
||||
revoked = true;
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
};
|
||||
releaseBlob = owner ? owner.add(revoke) : revoke;
|
||||
if (complete || request.closed) {
|
||||
releaseBlob();
|
||||
return;
|
||||
}
|
||||
const scaled = new Image();
|
||||
watch(scaled, () => finish2(scaled));
|
||||
if (!request.closed)
|
||||
scaled.src = blobUrl;
|
||||
} catch (error2) {
|
||||
fail(error2);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!request.closed)
|
||||
image.src = url;
|
||||
} catch (error2) {
|
||||
fail(error2);
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadImg(url, scale) {
|
||||
return requestImage(url, scale, void 0);
|
||||
}
|
||||
function loadThumbnailImage(url, scale, owner) {
|
||||
return requestImage(url, scale, owner);
|
||||
}
|
||||
function setStyleText(id2, style) {
|
||||
let $style = document.getElementById(id2);
|
||||
if (!$style) {
|
||||
@@ -598,38 +724,6 @@ function supportsFlex() {
|
||||
function getRect(el) {
|
||||
return el.getBoundingClientRect();
|
||||
}
|
||||
function loadImg(url, scale) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
if (!scale || scale === 1) {
|
||||
resolve(img);
|
||||
} else {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const scaledImg = new Image();
|
||||
scaledImg.onload = function() {
|
||||
resolve(scaledImg);
|
||||
};
|
||||
scaledImg.onerror = function() {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error(`Image load failed: ${url}`));
|
||||
};
|
||||
scaledImg.src = blobUrl;
|
||||
});
|
||||
}
|
||||
};
|
||||
img.onerror = function() {
|
||||
reject(new Error(`Image load failed: ${url}`));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
function getComposedPath(event) {
|
||||
if (event.composedPath)
|
||||
return event.composedPath();
|
||||
@@ -1619,6 +1713,75 @@ function playAndPause(option) {
|
||||
}
|
||||
});
|
||||
}
|
||||
const current = /* @__PURE__ */ new WeakMap();
|
||||
const assignments = /* @__PURE__ */ new WeakMap();
|
||||
const sources = /* @__PURE__ */ new WeakMap();
|
||||
function getSourceScope(owner) {
|
||||
return sources.get(owner) || getScope(owner);
|
||||
}
|
||||
function captureSource(owner) {
|
||||
const operation = current.get(owner);
|
||||
const source = sources.get(owner);
|
||||
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
|
||||
}
|
||||
function beginSource(owner) {
|
||||
const previous = sources.get(owner);
|
||||
const source = getScope(owner).child();
|
||||
const scope = source.child();
|
||||
sources.set(owner, source);
|
||||
source.add(() => {
|
||||
if (sources.get(owner) === source)
|
||||
sources.delete(owner);
|
||||
});
|
||||
const operation = {
|
||||
scope,
|
||||
assigned: false,
|
||||
acceptingEvents: false,
|
||||
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
|
||||
};
|
||||
current.set(owner, operation);
|
||||
scope.add(() => {
|
||||
if (current.get(owner) === operation)
|
||||
current.delete(owner);
|
||||
operation.onAssigned = void 0;
|
||||
operation.onError = void 0;
|
||||
});
|
||||
previous?.dispose();
|
||||
return operation;
|
||||
}
|
||||
function takeAssignment(owner) {
|
||||
const operation = assignments.get(owner);
|
||||
assignments.delete(owner);
|
||||
return operation || beginSource(owner);
|
||||
}
|
||||
function finishAssignment(operation) {
|
||||
operation.assigned = true;
|
||||
const callback = operation.onAssigned;
|
||||
operation.onAssigned = void 0;
|
||||
if (operation.active())
|
||||
callback?.();
|
||||
}
|
||||
function assignUrl(owner, operation, url) {
|
||||
assignments.set(owner, operation);
|
||||
operation.acceptingEvents = true;
|
||||
try {
|
||||
owner.url = url;
|
||||
} finally {
|
||||
if (assignments.get(owner) === operation) {
|
||||
assignments.delete(owner);
|
||||
finishAssignment(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
function failSource(operation, error2) {
|
||||
if (!operation.active())
|
||||
return;
|
||||
if (operation.onError)
|
||||
operation.onError(error2);
|
||||
else
|
||||
console.warn("Failed to initialize ArtPlayer source:", error2);
|
||||
operation.scope.dispose();
|
||||
}
|
||||
function getPosFromEvent(art, event) {
|
||||
const { $progress } = art.template;
|
||||
const { left } = getRect($progress);
|
||||
@@ -1630,14 +1793,20 @@ function getPosFromEvent(art, event) {
|
||||
return { second, time: time2, width, percentage };
|
||||
}
|
||||
function setCurrentTime(art, event, active2 = () => true) {
|
||||
if (!active2())
|
||||
return;
|
||||
if (art.isRotate) {
|
||||
const percentage = (event.touches[0].clientY - art.top) / art.height;
|
||||
const second = percentage * art.duration;
|
||||
if (!active2())
|
||||
return;
|
||||
art.emit("setBar", "played", percentage, event);
|
||||
if (active2())
|
||||
art.seek = second;
|
||||
} else {
|
||||
const { second, percentage } = getPosFromEvent(art, event);
|
||||
if (!active2())
|
||||
return;
|
||||
art.emit("setBar", "played", percentage, event);
|
||||
if (active2())
|
||||
art.seek = second;
|
||||
@@ -1647,34 +1816,51 @@ function installProgressInteractions(art, $control) {
|
||||
const { $progress } = art.template;
|
||||
const $indicator = $control.querySelector(".art-progress-indicator");
|
||||
const { on, proxy } = controlEvents(art, $control);
|
||||
const scope = entryScope($control);
|
||||
let action = 0;
|
||||
const capture = () => {
|
||||
const sourceActive = captureSource(art);
|
||||
return () => !scope.closed && !isClosing(art) && sourceActive();
|
||||
};
|
||||
const captureAction = () => {
|
||||
const revision = ++action;
|
||||
const active2 = capture();
|
||||
return () => revision === action && active2();
|
||||
};
|
||||
if (!isMobile) {
|
||||
let isDragging = false;
|
||||
let dragging;
|
||||
proxy($progress, "click", (event) => {
|
||||
if (event.target !== $indicator) {
|
||||
setCurrentTime(art, event);
|
||||
setCurrentTime(art, event, captureAction());
|
||||
}
|
||||
});
|
||||
proxy($progress, "mousemove", (event) => {
|
||||
const active2 = capture();
|
||||
const { percentage } = getPosFromEvent(art, event);
|
||||
art.emit("setBar", "hover", percentage, event);
|
||||
if (active2())
|
||||
art.emit("setBar", "hover", percentage, event);
|
||||
});
|
||||
proxy($progress, "mouseleave", (event) => {
|
||||
art.emit("setBar", "hover", 0, event);
|
||||
});
|
||||
proxy($progress, "mousedown", (event) => {
|
||||
isDragging = event.button === 0;
|
||||
dragging = event.button === 0 ? captureAction() : void 0;
|
||||
});
|
||||
on("document:mousemove", (event) => {
|
||||
if (isDragging) {
|
||||
const active2 = dragging;
|
||||
if (active2?.()) {
|
||||
const { second, percentage } = getPosFromEvent(art, event);
|
||||
if (!active2() || dragging !== active2)
|
||||
return;
|
||||
art.emit("setBar", "played", percentage, event);
|
||||
art.seek = second;
|
||||
if (active2() && dragging === active2)
|
||||
art.seek = second;
|
||||
} else {
|
||||
dragging = void 0;
|
||||
}
|
||||
});
|
||||
on("document:mouseup", () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
}
|
||||
dragging = void 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1814,7 +2000,7 @@ function screenshot$1(option) {
|
||||
const { icons } = art;
|
||||
appendElement($control, icons.screenshot);
|
||||
proxy($control, "click", () => {
|
||||
art.screenshot();
|
||||
silencePromise(art.screenshot());
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2278,75 +2464,6 @@ function clickInit(art, events) {
|
||||
}
|
||||
});
|
||||
}
|
||||
const current = /* @__PURE__ */ new WeakMap();
|
||||
const assignments = /* @__PURE__ */ new WeakMap();
|
||||
const sources = /* @__PURE__ */ new WeakMap();
|
||||
function getSourceScope(owner) {
|
||||
return sources.get(owner) || getScope(owner);
|
||||
}
|
||||
function captureSource(owner) {
|
||||
const operation = current.get(owner);
|
||||
const source = sources.get(owner);
|
||||
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
|
||||
}
|
||||
function beginSource(owner) {
|
||||
const previous = sources.get(owner);
|
||||
const source = getScope(owner).child();
|
||||
const scope = source.child();
|
||||
sources.set(owner, source);
|
||||
source.add(() => {
|
||||
if (sources.get(owner) === source)
|
||||
sources.delete(owner);
|
||||
});
|
||||
const operation = {
|
||||
scope,
|
||||
assigned: false,
|
||||
acceptingEvents: false,
|
||||
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
|
||||
};
|
||||
current.set(owner, operation);
|
||||
scope.add(() => {
|
||||
if (current.get(owner) === operation)
|
||||
current.delete(owner);
|
||||
operation.onAssigned = void 0;
|
||||
operation.onError = void 0;
|
||||
});
|
||||
previous?.dispose();
|
||||
return operation;
|
||||
}
|
||||
function takeAssignment(owner) {
|
||||
const operation = assignments.get(owner);
|
||||
assignments.delete(owner);
|
||||
return operation || beginSource(owner);
|
||||
}
|
||||
function finishAssignment(operation) {
|
||||
operation.assigned = true;
|
||||
const callback = operation.onAssigned;
|
||||
operation.onAssigned = void 0;
|
||||
if (operation.active())
|
||||
callback?.();
|
||||
}
|
||||
function assignUrl(owner, operation, url) {
|
||||
assignments.set(owner, operation);
|
||||
operation.acceptingEvents = true;
|
||||
try {
|
||||
owner.url = url;
|
||||
} finally {
|
||||
if (assignments.get(owner) === operation) {
|
||||
assignments.delete(owner);
|
||||
finishAssignment(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
function failSource(operation, error2) {
|
||||
if (!operation.active())
|
||||
return;
|
||||
if (operation.onError)
|
||||
operation.onError(error2);
|
||||
else
|
||||
console.warn("Failed to initialize ArtPlayer source:", error2);
|
||||
operation.scope.dispose();
|
||||
}
|
||||
function slideDirection(startX, startY, endX, endY) {
|
||||
const dy = startY - endY;
|
||||
const dx = endX - startX;
|
||||
@@ -3473,6 +3590,13 @@ function cssVarMix(art) {
|
||||
}
|
||||
});
|
||||
}
|
||||
const revisions = /* @__PURE__ */ new WeakMap();
|
||||
function positionRevision(art) {
|
||||
return revisions.get(art) || 0;
|
||||
}
|
||||
function advancePosition(art) {
|
||||
revisions.set(art, positionRevision(art) + 1);
|
||||
}
|
||||
function currentTimeMix(art) {
|
||||
const { $video } = art.template;
|
||||
def(art, "currentTime", {
|
||||
@@ -3481,6 +3605,7 @@ function currentTimeMix(art) {
|
||||
const parsed = Number.parseFloat(time2);
|
||||
if (Number.isNaN(parsed))
|
||||
return;
|
||||
advancePosition(art);
|
||||
$video.currentTime = clamp(parsed, 0, art.duration);
|
||||
}
|
||||
});
|
||||
@@ -4939,46 +5064,66 @@ function rectMix(art) {
|
||||
}
|
||||
});
|
||||
}
|
||||
function drawFrame(canvas, video) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context)
|
||||
throw new TypeError("Canvas 2D context is unavailable");
|
||||
context.drawImage(video, 0, 0);
|
||||
}
|
||||
function captureBlobUrl(canvas, resolve, reject) {
|
||||
canvas.toBlob((blob) => {
|
||||
try {
|
||||
if (!blob)
|
||||
throw new Error("Unable to encode screenshot blob");
|
||||
resolve(URL.createObjectURL(blob));
|
||||
} catch (error2) {
|
||||
reject(error2);
|
||||
}
|
||||
});
|
||||
}
|
||||
function screenshotMix(art) {
|
||||
const {
|
||||
notice,
|
||||
template: { $video }
|
||||
} = art;
|
||||
const $canvas = createElement("canvas");
|
||||
def(art, "getDataURL", {
|
||||
value: () => new Promise((resolve, reject) => {
|
||||
const { notice, template: { $video } } = art;
|
||||
const canvas = document.createElement("canvas");
|
||||
function capture(blob) {
|
||||
const active2 = captureSource(art);
|
||||
return new Promise((resolve, reject) => {
|
||||
const fail = (error2) => {
|
||||
try {
|
||||
if (!isClosing(art) && active2())
|
||||
notice.show = error2;
|
||||
} catch (noticeError) {
|
||||
reject(noticeError);
|
||||
return;
|
||||
}
|
||||
reject(error2);
|
||||
};
|
||||
try {
|
||||
$canvas.width = $video.videoWidth;
|
||||
$canvas.height = $video.videoHeight;
|
||||
$canvas.getContext("2d").drawImage($video, 0, 0);
|
||||
resolve($canvas.toDataURL("image/png"));
|
||||
} catch (err) {
|
||||
notice.show = err;
|
||||
reject(err);
|
||||
drawFrame(canvas, $video);
|
||||
if (blob) {
|
||||
captureBlobUrl(canvas, resolve, fail);
|
||||
} else {
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
}
|
||||
} catch (error2) {
|
||||
fail(error2);
|
||||
}
|
||||
})
|
||||
});
|
||||
def(art, "getBlobUrl", {
|
||||
value: () => new Promise((resolve, reject) => {
|
||||
try {
|
||||
$canvas.width = $video.videoWidth;
|
||||
$canvas.height = $video.videoHeight;
|
||||
$canvas.getContext("2d").drawImage($video, 0, 0);
|
||||
$canvas.toBlob((blob) => {
|
||||
resolve(URL.createObjectURL(blob));
|
||||
});
|
||||
} catch (err) {
|
||||
notice.show = err;
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
def(art, "getDataURL", { value: () => capture(false) });
|
||||
def(art, "getBlobUrl", { value: () => capture(true) });
|
||||
def(art, "screenshot", {
|
||||
value: async (name) => {
|
||||
const active2 = captureSource(art);
|
||||
const dataUri = await art.getDataURL();
|
||||
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`;
|
||||
download(dataUri, `${fileName}.png`);
|
||||
art.emit("screenshot", dataUri);
|
||||
if (!isClosing(art) && active2()) {
|
||||
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`;
|
||||
if (!isClosing(art) && active2())
|
||||
download(dataUri, `${fileName}.png`);
|
||||
if (!isClosing(art) && active2())
|
||||
art.emit("screenshot", dataUri);
|
||||
}
|
||||
return dataUri;
|
||||
}
|
||||
});
|
||||
@@ -5061,6 +5206,48 @@ function listenSource(scope, art, name, callback, once = true) {
|
||||
});
|
||||
return guarded;
|
||||
}
|
||||
const POSITION_TOLERANCE = 0.05;
|
||||
function positionRestoration(art, target, active2) {
|
||||
let expected;
|
||||
let observedSeek = false;
|
||||
let corrected = false;
|
||||
let manual = false;
|
||||
let revision = positionRevision(art);
|
||||
const manualPosition = () => manual || positionRevision(art) !== revision;
|
||||
const seeking = () => !!art.template?.$video?.seeking;
|
||||
return {
|
||||
restore() {
|
||||
if (!active2() || manualPosition())
|
||||
return;
|
||||
const previousRevision = positionRevision(art);
|
||||
art.currentTime = target;
|
||||
revision = positionRevision(art);
|
||||
if (revision > previousRevision + 1)
|
||||
manual = true;
|
||||
if (!active2())
|
||||
return;
|
||||
expected = art.currentTime;
|
||||
if (!active2() || manualPosition())
|
||||
return;
|
||||
observedSeek = seeking();
|
||||
},
|
||||
manual() {
|
||||
manual = true;
|
||||
},
|
||||
ready() {
|
||||
if (!active2() || seeking())
|
||||
return false;
|
||||
const missed = expected !== void 0 && Math.abs(art.currentTime - expected) > POSITION_TOLERANCE;
|
||||
if (!manualPosition() && observedSeek && !corrected && expected !== void 0 && missed) {
|
||||
corrected = true;
|
||||
if (!active2())
|
||||
return false;
|
||||
art.currentTime = expected;
|
||||
}
|
||||
return active2() && !seeking();
|
||||
}
|
||||
};
|
||||
}
|
||||
function switchSource(art, url, currentTime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isClosing(art) || url === art.url) {
|
||||
@@ -5096,41 +5283,63 @@ function switchSource(art, url, currentTime) {
|
||||
if (!active2())
|
||||
return;
|
||||
const readiness = scope.child();
|
||||
let canPlay = false;
|
||||
let resuming = false;
|
||||
let rateRestored = false;
|
||||
const position = positionRestoration(art, currentTime, active2);
|
||||
const resume = async () => {
|
||||
art.aspectRatio = aspectRatio2;
|
||||
if (!active2())
|
||||
return;
|
||||
if (playing) {
|
||||
try {
|
||||
await art.play();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (!active2())
|
||||
return;
|
||||
art.notice.show = "";
|
||||
settle();
|
||||
};
|
||||
const resumeWhenReady = () => {
|
||||
if (!active2() || resuming || !canPlay)
|
||||
return;
|
||||
try {
|
||||
if (art.template?.$video?.seeking)
|
||||
return;
|
||||
if (!active2())
|
||||
return;
|
||||
if (!rateRestored) {
|
||||
rateRestored = true;
|
||||
art.playbackRate = playbackRate2;
|
||||
}
|
||||
if (!position.ready() || !active2() || resuming)
|
||||
return;
|
||||
resuming = true;
|
||||
readiness.dispose();
|
||||
void resume().catch(fail);
|
||||
} catch (error2) {
|
||||
fail(error2);
|
||||
}
|
||||
};
|
||||
const handlers = {
|
||||
"video:error": fail,
|
||||
"video:loadedmetadata": () => {
|
||||
if (!active2())
|
||||
return;
|
||||
try {
|
||||
art.currentTime = currentTime;
|
||||
position.restore();
|
||||
} catch (error2) {
|
||||
fail(error2);
|
||||
}
|
||||
},
|
||||
"video:canplay": () => {
|
||||
if (!active2())
|
||||
return;
|
||||
readiness.dispose();
|
||||
const resume = async () => {
|
||||
art.playbackRate = playbackRate2;
|
||||
if (!active2())
|
||||
return;
|
||||
art.aspectRatio = aspectRatio2;
|
||||
if (!active2())
|
||||
return;
|
||||
if (playing) {
|
||||
try {
|
||||
await art.play();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
if (!active2())
|
||||
return;
|
||||
art.notice.show = "";
|
||||
settle();
|
||||
};
|
||||
void resume().catch(fail);
|
||||
}
|
||||
canPlay = true;
|
||||
resumeWhenReady();
|
||||
},
|
||||
"video:seeked": resumeWhenReady,
|
||||
"seek": position.manual
|
||||
};
|
||||
const capture = scope.child();
|
||||
const queued = [];
|
||||
@@ -5155,7 +5364,7 @@ function switchSource(art, url, currentTime) {
|
||||
}
|
||||
const dispatch = {};
|
||||
for (const name of names)
|
||||
dispatch[name] = listenSource(readiness, art, name, handlers[name]);
|
||||
dispatch[name] = listenSource(readiness, art, name, handlers[name], name !== "video:seeked");
|
||||
for (const [name, event] of queued) {
|
||||
if (!active2() || readiness.closed)
|
||||
break;
|
||||
@@ -5188,69 +5397,106 @@ function themeMix(art) {
|
||||
}
|
||||
});
|
||||
}
|
||||
function thumbnailLayout(option, geometry) {
|
||||
const { number, column, width, height, scale } = option;
|
||||
const { imageWidth, videoWidth, videoHeight, progressWidth, position } = geometry;
|
||||
const columns = Number(column);
|
||||
const previewWidth = Number(width) * Number(scale) || imageWidth / columns;
|
||||
const previewHeight = Number(height) * Number(scale) || previewWidth / (videoWidth / videoHeight);
|
||||
const index = Math.floor(position / (progressWidth / Number(number)));
|
||||
const row = Math.floor(index / columns);
|
||||
const cell = index % columns;
|
||||
const left = position <= previewWidth / 2 ? 0 : position > progressWidth - previewWidth / 2 ? `${progressWidth - previewWidth}px` : `${position - previewWidth / 2}px`;
|
||||
return {
|
||||
height: `${previewHeight}px`,
|
||||
width: `${previewWidth}px`,
|
||||
backgroundPosition: `-${cell * previewWidth}px -${row * previewHeight}px`,
|
||||
left
|
||||
};
|
||||
}
|
||||
function thumbnailsMix(art) {
|
||||
const {
|
||||
option,
|
||||
template: { $progress, $video }
|
||||
} = art;
|
||||
let timer = null;
|
||||
let loding = false;
|
||||
let image = null;
|
||||
const { option, template: { $progress, $video } } = art;
|
||||
let scope = getScope(art).child();
|
||||
let image;
|
||||
let loading2 = false;
|
||||
let control;
|
||||
let hover;
|
||||
function reset() {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
loding = false;
|
||||
image = null;
|
||||
const previous = scope;
|
||||
scope = getScope(art).child();
|
||||
image = void 0;
|
||||
loading2 = false;
|
||||
hover = void 0;
|
||||
previous.dispose();
|
||||
}
|
||||
function showThumbnails(posWidth) {
|
||||
const $thumbnails = art.controls?.thumbnails;
|
||||
if (!$thumbnails)
|
||||
function render() {
|
||||
const current2 = hover;
|
||||
const currentScope = scope;
|
||||
if (!current2 || !image)
|
||||
return;
|
||||
const { number, column, width, height, scale } = option.thumbnails;
|
||||
const width2 = width * scale || image.naturalWidth / column;
|
||||
const height2 = height * scale || width2 / ($video.videoWidth / $video.videoHeight);
|
||||
const perWidth = $progress.clientWidth / number;
|
||||
const perIndex = Math.floor(posWidth / perWidth);
|
||||
const yIndex = Math.ceil(perIndex / column) - 1;
|
||||
const xIndex = perIndex % column || column - 1;
|
||||
setStyle($thumbnails, "backgroundImage", `url(${image.src})`);
|
||||
setStyle($thumbnails, "height", `${height2}px`);
|
||||
setStyle($thumbnails, "width", `${width2}px`);
|
||||
setStyle($thumbnails, "backgroundPosition", `-${xIndex * width2}px -${yIndex * height2}px`);
|
||||
if (posWidth <= width2 / 2) {
|
||||
setStyle($thumbnails, "left", 0);
|
||||
} else if (posWidth > $progress.clientWidth - width2 / 2) {
|
||||
setStyle($thumbnails, "left", `${$progress.clientWidth - width2}px`);
|
||||
} else {
|
||||
setStyle($thumbnails, "left", `${posWidth - width2 / 2}px`);
|
||||
const active2 = () => !isClosing(art) && !currentScope.closed && scope === currentScope && hover === current2 && current2.sourceActive() && art.controls?.thumbnails === current2.element;
|
||||
if (!active2())
|
||||
return;
|
||||
const position = $progress.clientWidth * current2.percentage;
|
||||
if (!(position > 0 && position < $progress.clientWidth))
|
||||
return;
|
||||
const styles = {
|
||||
backgroundImage: `url(${image.src})`,
|
||||
...thumbnailLayout(option.thumbnails, {
|
||||
imageWidth: image.naturalWidth,
|
||||
videoWidth: $video.videoWidth,
|
||||
videoHeight: $video.videoHeight,
|
||||
progressWidth: $progress.clientWidth,
|
||||
position
|
||||
})
|
||||
};
|
||||
for (const [key, value] of Object.entries(styles)) {
|
||||
if (!active2())
|
||||
return;
|
||||
setStyle(current2.element, key, value);
|
||||
}
|
||||
}
|
||||
art.on("setBar", async (type, percentage, event) => {
|
||||
const $thumbnails = art.controls?.thumbnails;
|
||||
eventSubscriptions(art)("setBar", (type, percentage, event) => {
|
||||
const element = art.controls?.thumbnails;
|
||||
const { url, scale } = option.thumbnails;
|
||||
if (!$thumbnails || !url)
|
||||
if (isClosing(art) || !element || !url || !(type === "hover" || type === "played" && event && isMobile))
|
||||
return;
|
||||
const isMobileDragging = type === "played" && event && isMobile;
|
||||
if (type === "hover" || isMobileDragging) {
|
||||
if (!image && !loding) {
|
||||
loding = true;
|
||||
image = await loadImg(url, scale);
|
||||
loding = false;
|
||||
}
|
||||
if (!image)
|
||||
if (control !== element) {
|
||||
const elementScope = entryScope(element);
|
||||
if (elementScope.closed)
|
||||
return;
|
||||
const width = $progress.clientWidth * percentage;
|
||||
if (width > 0 && width < $progress.clientWidth) {
|
||||
showThumbnails(width);
|
||||
}
|
||||
control = element;
|
||||
elementScope.add(() => {
|
||||
if (control === element) {
|
||||
control = void 0;
|
||||
reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
hover = { percentage, element, sourceActive: captureSource(art) };
|
||||
if (image) {
|
||||
render();
|
||||
} else if (!loading2) {
|
||||
const currentScope = scope;
|
||||
loading2 = true;
|
||||
loadThumbnailImage(url, scale, currentScope).then((loaded) => {
|
||||
if (scope !== currentScope || currentScope.closed)
|
||||
return;
|
||||
loading2 = false;
|
||||
image = loaded;
|
||||
render();
|
||||
}).catch((error2) => {
|
||||
if (scope === currentScope && !currentScope.closed) {
|
||||
loading2 = false;
|
||||
console.warn("ArtPlayer thumbnail load failed:", error2);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
def(art, "thumbnails", {
|
||||
get() {
|
||||
return art.option.thumbnails;
|
||||
},
|
||||
set(thumbnails) {
|
||||
if (thumbnails.url && !art.option.isLive) {
|
||||
get: () => art.option.thumbnails,
|
||||
set: (thumbnails) => {
|
||||
if (!isClosing(art) && thumbnails.url && !art.option.isLive && !isClosing(art)) {
|
||||
art.option.thumbnails = thumbnails;
|
||||
reset();
|
||||
}
|
||||
@@ -5319,8 +5565,6 @@ function urlMix(art) {
|
||||
const result = typeCallback.call(art, $video, newUrl, art);
|
||||
void Promise.resolve(result).catch((error2) => failSource(operation, error2));
|
||||
} else {
|
||||
if (oldUrl)
|
||||
URL.revokeObjectURL(oldUrl);
|
||||
if (!operation.active())
|
||||
return;
|
||||
$video.src = newUrl;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type CaptureMedia = CanvasImageSource & {
|
||||
videoWidth: number
|
||||
videoHeight: number
|
||||
currentTime: number
|
||||
}
|
||||
|
||||
export function drawFrame(canvas: HTMLCanvasElement, video: CaptureMedia): void {
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context)
|
||||
throw new TypeError('Canvas 2D context is unavailable')
|
||||
context.drawImage(video, 0, 0)
|
||||
}
|
||||
|
||||
export function captureBlobUrl(canvas: HTMLCanvasElement, resolve: (url: string) => void, reject: (error: unknown) => void): void {
|
||||
canvas.toBlob((blob) => {
|
||||
try {
|
||||
if (!blob)
|
||||
throw new Error('Unable to encode screenshot blob')
|
||||
// The public getBlobUrl caller owns this URL, including after destroy.
|
||||
resolve(URL.createObjectURL(blob))
|
||||
}
|
||||
catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ControlHost } from '../types'
|
||||
import { entryScope } from '../../component/resources'
|
||||
import { isClosing } from '../../lifecycle/instance'
|
||||
import { captureSource } from '../../source/operation'
|
||||
import { isMobile } from '../../utils'
|
||||
import { controlEvents } from '../resources'
|
||||
import { getPosFromEvent, setCurrentTime } from './position'
|
||||
@@ -7,17 +10,30 @@ export function installProgressInteractions(art: ControlHost, $control: HTMLDivE
|
||||
const { $progress } = art.template
|
||||
const $indicator = $control.querySelector('.art-progress-indicator')
|
||||
const { on, proxy } = controlEvents(art, $control)
|
||||
const scope = entryScope($control)
|
||||
let action = 0
|
||||
const capture = () => {
|
||||
const sourceActive = captureSource(art)
|
||||
return () => !scope.closed && !isClosing(art) && sourceActive()
|
||||
}
|
||||
const captureAction = () => {
|
||||
const revision = ++action
|
||||
const active = capture()
|
||||
return () => revision === action && active()
|
||||
}
|
||||
if (!isMobile) {
|
||||
let isDragging = false
|
||||
let dragging: (() => boolean) | undefined
|
||||
proxy($progress, 'click', (event) => {
|
||||
if (event.target !== $indicator) {
|
||||
setCurrentTime(art, event)
|
||||
setCurrentTime(art, event, captureAction())
|
||||
}
|
||||
})
|
||||
|
||||
proxy($progress, 'mousemove', (event) => {
|
||||
const active = capture()
|
||||
const { percentage } = getPosFromEvent(art, event)
|
||||
art.emit('setBar', 'hover', percentage, event)
|
||||
if (active())
|
||||
art.emit('setBar', 'hover', percentage, event)
|
||||
})
|
||||
|
||||
proxy($progress, 'mouseleave', (event) => {
|
||||
@@ -25,21 +41,26 @@ export function installProgressInteractions(art: ControlHost, $control: HTMLDivE
|
||||
})
|
||||
|
||||
proxy($progress, 'mousedown', (event) => {
|
||||
isDragging = event.button === 0
|
||||
dragging = event.button === 0 ? captureAction() : undefined
|
||||
})
|
||||
|
||||
on('document:mousemove', (event) => {
|
||||
if (isDragging) {
|
||||
const active = dragging
|
||||
if (active?.()) {
|
||||
const { second, percentage } = getPosFromEvent(art, event)
|
||||
if (!active() || dragging !== active)
|
||||
return
|
||||
art.emit('setBar', 'played', percentage, event)
|
||||
art.seek = second
|
||||
if (active() && dragging === active)
|
||||
art.seek = second
|
||||
}
|
||||
else {
|
||||
dragging = undefined
|
||||
}
|
||||
})
|
||||
|
||||
on('document:mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false
|
||||
}
|
||||
dragging = undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,21 @@ export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent)
|
||||
}
|
||||
|
||||
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent, active: () => boolean = () => true) {
|
||||
if (!active())
|
||||
return
|
||||
if (art.isRotate) {
|
||||
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
|
||||
const second = percentage * art.duration
|
||||
if (!active())
|
||||
return
|
||||
art.emit('setBar', 'played', percentage, event)
|
||||
if (active())
|
||||
art.seek = second
|
||||
}
|
||||
else {
|
||||
const { second, percentage } = getPosFromEvent(art, event)
|
||||
if (!active())
|
||||
return
|
||||
art.emit('setBar', 'played', percentage, event)
|
||||
if (active())
|
||||
art.seek = second
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { silencePromise } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
export default function screenshot(option: ControlOption): ControlFactory {
|
||||
@@ -12,7 +13,7 @@ export default function screenshot(option: ControlOption): ControlFactory {
|
||||
|
||||
appendElement($control, icons.screenshot)
|
||||
proxy($control, 'click', () => {
|
||||
art.screenshot()
|
||||
silencePromise(art.screenshot())
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import ResourceScope from '../lifecycle/scope'
|
||||
|
||||
function requestImage(url: string, scale: number | undefined, owner: undefined): Promise<HTMLImageElement>
|
||||
function requestImage(url: string, scale: number | undefined, owner: ResourceScope): Promise<HTMLImageElement | undefined>
|
||||
function requestImage(url: string, scale: number | undefined, owner: ResourceScope | undefined): Promise<HTMLImageElement | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = owner ? owner.child() : new ResourceScope()
|
||||
let complete = false
|
||||
let releaseBlob = () => {}
|
||||
|
||||
function fail(error: unknown): void {
|
||||
if (complete)
|
||||
return
|
||||
complete = true
|
||||
try {
|
||||
request.dispose()
|
||||
}
|
||||
catch (cleanupError) {
|
||||
console.warn('Additional image cleanup failure:', cleanupError)
|
||||
}
|
||||
try {
|
||||
releaseBlob()
|
||||
}
|
||||
catch (cleanupError) {
|
||||
console.warn('Additional image cleanup failure:', cleanupError)
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
|
||||
function finish(image: HTMLImageElement): void {
|
||||
if (complete || request.closed)
|
||||
return
|
||||
complete = true
|
||||
try {
|
||||
request.dispose()
|
||||
resolve(image)
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
releaseBlob()
|
||||
}
|
||||
finally {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.add(() => {
|
||||
if (!complete) {
|
||||
complete = true
|
||||
try {
|
||||
releaseBlob()
|
||||
}
|
||||
finally {
|
||||
resolve(undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (request.closed)
|
||||
return
|
||||
|
||||
function watch(image: HTMLImageElement, loaded: () => void): void {
|
||||
image.onload = () => {
|
||||
if (!complete && !request.closed) {
|
||||
try {
|
||||
loaded()
|
||||
}
|
||||
catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
image.onerror = () => fail(new Error(`Image load failed: ${url}`))
|
||||
request.add(() => {
|
||||
image.onload = null
|
||||
image.onerror = null
|
||||
if (!complete)
|
||||
image.removeAttribute('src')
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const image = new Image()
|
||||
watch(image, () => {
|
||||
if (!scale || scale === 1) {
|
||||
finish(image)
|
||||
return
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
canvas.width = image.width * scale
|
||||
canvas.height = image.height * scale
|
||||
if (!context)
|
||||
throw new TypeError('Canvas 2D context is unavailable')
|
||||
if (complete || request.closed)
|
||||
return
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
||||
if (complete || request.closed)
|
||||
return
|
||||
canvas.toBlob((blob) => {
|
||||
if (complete || request.closed)
|
||||
return
|
||||
try {
|
||||
if (!blob)
|
||||
throw new Error('Unable to encode thumbnail image')
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
let revoked = false
|
||||
const revoke = (): undefined => {
|
||||
if (!revoked) {
|
||||
revoked = true
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}
|
||||
releaseBlob = owner ? owner.add(revoke) : revoke
|
||||
if (complete || request.closed) {
|
||||
releaseBlob()
|
||||
return
|
||||
}
|
||||
const scaled = new Image()
|
||||
watch(scaled, () => finish(scaled))
|
||||
if (!request.closed)
|
||||
scaled.src = blobUrl
|
||||
}
|
||||
catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
if (!request.closed)
|
||||
image.src = url
|
||||
}
|
||||
catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Public callers own a scaled result's image.src object URL.
|
||||
export function loadImg(url: string, scale?: number): Promise<HTMLImageElement> {
|
||||
return requestImage(url, scale, undefined)
|
||||
}
|
||||
|
||||
// Internal previews own their result until configuration replacement or destroy.
|
||||
export function loadThumbnailImage(url: string, scale: number | undefined, owner: ResourceScope): Promise<HTMLImageElement | undefined> {
|
||||
return requestImage(url, scale, owner)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const revisions = new WeakMap<object, number>()
|
||||
|
||||
export function positionRevision(art: object): number {
|
||||
return revisions.get(art) || 0
|
||||
}
|
||||
|
||||
export function advancePosition(art: object): void {
|
||||
revisions.set(art, positionRevision(art) + 1)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MediaHost } from '../media/hosts'
|
||||
import type { Position } from '../media/playback'
|
||||
import { advancePosition } from '../media/position-revision'
|
||||
import { clamp, def } from '../utils'
|
||||
|
||||
export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'currentTime'>> & Pick<Position, 'duration'>): asserts art is typeof art & Position {
|
||||
@@ -11,6 +12,7 @@ export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'cu
|
||||
const parsed = Number.parseFloat(time as string)
|
||||
if (Number.isNaN(parsed))
|
||||
return
|
||||
advancePosition(art)
|
||||
$video.currentTime = clamp(parsed, 0, art.duration)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createElement, def, download, secondToTime } from '../utils'
|
||||
|
||||
export default function screenshotMix(art) {
|
||||
const {
|
||||
notice,
|
||||
template: { $video },
|
||||
} = art
|
||||
|
||||
const $canvas = createElement('canvas')
|
||||
|
||||
def(art, 'getDataURL', {
|
||||
value: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
$canvas.width = $video.videoWidth
|
||||
$canvas.height = $video.videoHeight
|
||||
$canvas.getContext('2d').drawImage($video, 0, 0)
|
||||
resolve($canvas.toDataURL('image/png'))
|
||||
}
|
||||
catch (err) {
|
||||
notice.show = err
|
||||
reject(err)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
def(art, 'getBlobUrl', {
|
||||
value: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
$canvas.width = $video.videoWidth
|
||||
$canvas.height = $video.videoHeight
|
||||
$canvas.getContext('2d').drawImage($video, 0, 0)
|
||||
$canvas.toBlob((blob) => {
|
||||
resolve(URL.createObjectURL(blob))
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
notice.show = err
|
||||
reject(err)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
def(art, 'screenshot', {
|
||||
value: async (name) => {
|
||||
const dataUri = await art.getDataURL()
|
||||
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`
|
||||
download(dataUri, `${fileName}.png`)
|
||||
art.emit('screenshot', dataUri)
|
||||
return dataUri
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CaptureMedia } from '../capture/frame'
|
||||
import { captureBlobUrl, drawFrame } from '../capture/frame'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { captureSource } from '../source/operation'
|
||||
import { def, download, secondToTime } from '../utils'
|
||||
|
||||
export interface ScreenshotHost {
|
||||
template: { $video: CaptureMedia }
|
||||
notice: { set show(value: unknown) }
|
||||
getDataURL: () => Promise<string>
|
||||
emit: (name: 'screenshot', dataUri: string) => unknown
|
||||
}
|
||||
|
||||
export default function screenshotMix(art: ScreenshotHost): void {
|
||||
const { notice, template: { $video } } = art
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
function capture(blob: boolean): Promise<string> {
|
||||
const active = captureSource(art)
|
||||
// Draw synchronously as before; awaiting must not capture a later frame.
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const fail = (error: unknown) => {
|
||||
try {
|
||||
if (!isClosing(art) && active())
|
||||
notice.show = error
|
||||
}
|
||||
catch (noticeError) {
|
||||
reject(noticeError)
|
||||
return
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
try {
|
||||
drawFrame(canvas, $video)
|
||||
if (blob) {
|
||||
// Handle failures inside toBlob as well as synchronous canvas errors.
|
||||
captureBlobUrl(canvas, resolve, fail)
|
||||
}
|
||||
else {
|
||||
resolve(canvas.toDataURL('image/png'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
def(art, 'getDataURL', { value: () => capture(false) })
|
||||
def(art, 'getBlobUrl', { value: () => capture(true) })
|
||||
def(art, 'screenshot', {
|
||||
value: async (name?: string): Promise<string> => {
|
||||
const active = captureSource(art)
|
||||
const dataUri = await art.getDataURL()
|
||||
if (!isClosing(art) && active()) {
|
||||
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`
|
||||
if (!isClosing(art) && active())
|
||||
download(dataUri, `${fileName}.png`)
|
||||
if (!isClosing(art) && active())
|
||||
art.emit('screenshot', dataUri)
|
||||
}
|
||||
return dataUri
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { def, isMobile, loadImg, setStyle } from '../utils'
|
||||
|
||||
export default function thumbnailsMix(art) {
|
||||
const {
|
||||
option,
|
||||
template: { $progress, $video },
|
||||
} = art
|
||||
|
||||
let timer = null
|
||||
let loding = false
|
||||
let image = null
|
||||
|
||||
function reset() {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
loding = false
|
||||
image = null
|
||||
}
|
||||
|
||||
function showThumbnails(posWidth) {
|
||||
const $thumbnails = art.controls?.thumbnails
|
||||
if (!$thumbnails)
|
||||
return
|
||||
|
||||
const { number, column, width, height, scale } = option.thumbnails
|
||||
const width2 = width * scale || image.naturalWidth / column
|
||||
const height2 = height * scale || width2 / ($video.videoWidth / $video.videoHeight)
|
||||
const perWidth = $progress.clientWidth / number
|
||||
const perIndex = Math.floor(posWidth / perWidth)
|
||||
const yIndex = Math.ceil(perIndex / column) - 1
|
||||
const xIndex = perIndex % column || column - 1
|
||||
setStyle($thumbnails, 'backgroundImage', `url(${image.src})`)
|
||||
setStyle($thumbnails, 'height', `${height2}px`)
|
||||
setStyle($thumbnails, 'width', `${width2}px`)
|
||||
setStyle($thumbnails, 'backgroundPosition', `-${xIndex * width2}px -${yIndex * height2}px`)
|
||||
if (posWidth <= width2 / 2) {
|
||||
setStyle($thumbnails, 'left', 0)
|
||||
}
|
||||
else if (posWidth > $progress.clientWidth - width2 / 2) {
|
||||
setStyle($thumbnails, 'left', `${$progress.clientWidth - width2}px`)
|
||||
}
|
||||
else {
|
||||
setStyle($thumbnails, 'left', `${posWidth - width2 / 2}px`)
|
||||
}
|
||||
}
|
||||
|
||||
art.on('setBar', async (type, percentage, event) => {
|
||||
const $thumbnails = art.controls?.thumbnails
|
||||
const { url, scale } = option.thumbnails
|
||||
if (!$thumbnails || !url)
|
||||
return
|
||||
|
||||
const isMobileDragging = type === 'played' && event && isMobile
|
||||
|
||||
if (type === 'hover' || isMobileDragging) {
|
||||
if (!image && !loding) {
|
||||
loding = true
|
||||
image = await loadImg(url, scale)
|
||||
loding = false
|
||||
}
|
||||
|
||||
if (!image)
|
||||
return
|
||||
|
||||
const width = $progress.clientWidth * percentage
|
||||
|
||||
if (width > 0 && width < $progress.clientWidth) {
|
||||
showThumbnails(width)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
def(art, 'thumbnails', {
|
||||
get() {
|
||||
return art.option.thumbnails
|
||||
},
|
||||
set(thumbnails) {
|
||||
if (thumbnails.url && !art.option.isLive) {
|
||||
art.option.thumbnails = thumbnails
|
||||
reset()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Thumbnails } from '../../types/option'
|
||||
import type { SubscriptionHost } from '../component/resources'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { eventSubscriptions } from '../events/subscriptions'
|
||||
import { loadThumbnailImage } from '../image/load'
|
||||
import { getScope, isClosing } from '../lifecycle/instance'
|
||||
import { captureSource } from '../source/operation'
|
||||
import { thumbnailLayout } from '../thumbnails/layout'
|
||||
import { def, isMobile, setStyle } from '../utils'
|
||||
|
||||
interface ThumbnailEvents {
|
||||
setBar: [type: string, percentage: number, event?: Event]
|
||||
}
|
||||
|
||||
export interface ThumbnailsHost extends SubscriptionHost<ThumbnailEvents> {
|
||||
option: { thumbnails: Thumbnails, isLive: boolean }
|
||||
template: { $progress: HTMLElement, $video: { videoWidth: number, videoHeight: number } }
|
||||
controls?: { thumbnails?: HTMLDivElement }
|
||||
}
|
||||
|
||||
export default function thumbnailsMix(art: ThumbnailsHost): void {
|
||||
const { option, template: { $progress, $video } } = art
|
||||
let scope = getScope(art).child()
|
||||
let image: HTMLImageElement | undefined
|
||||
let loading = false
|
||||
let control: HTMLDivElement | undefined
|
||||
let hover: { percentage: number, element: HTMLDivElement, sourceActive: () => boolean } | undefined
|
||||
|
||||
function reset(): void {
|
||||
const previous = scope
|
||||
scope = getScope(art).child()
|
||||
image = undefined
|
||||
loading = false
|
||||
hover = undefined
|
||||
previous.dispose()
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const current = hover
|
||||
const currentScope = scope
|
||||
if (!current || !image)
|
||||
return
|
||||
const active = () => !isClosing(art) && !currentScope.closed && scope === currentScope
|
||||
&& hover === current && current.sourceActive() && art.controls?.thumbnails === current.element
|
||||
if (!active())
|
||||
return
|
||||
const position = $progress.clientWidth * current.percentage
|
||||
if (!(position > 0 && position < $progress.clientWidth))
|
||||
return
|
||||
const styles = {
|
||||
backgroundImage: `url(${image.src})`,
|
||||
...thumbnailLayout(option.thumbnails, {
|
||||
imageWidth: image.naturalWidth,
|
||||
videoWidth: $video.videoWidth,
|
||||
videoHeight: $video.videoHeight,
|
||||
progressWidth: $progress.clientWidth,
|
||||
position,
|
||||
}),
|
||||
}
|
||||
for (const [key, value] of Object.entries(styles)) {
|
||||
if (!active())
|
||||
return
|
||||
setStyle(current.element, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
eventSubscriptions<ThumbnailEvents>(art)('setBar', (type, percentage, event) => {
|
||||
const element = art.controls?.thumbnails
|
||||
const { url, scale } = option.thumbnails
|
||||
if (isClosing(art) || !element || !url || !(type === 'hover' || (type === 'played' && event && isMobile)))
|
||||
return
|
||||
if (control !== element) {
|
||||
const elementScope = entryScope(element)
|
||||
if (elementScope.closed)
|
||||
return
|
||||
control = element
|
||||
elementScope.add(() => {
|
||||
if (control === element) {
|
||||
control = undefined
|
||||
reset()
|
||||
}
|
||||
})
|
||||
}
|
||||
hover = { percentage, element, sourceActive: captureSource(art) }
|
||||
if (image) {
|
||||
render()
|
||||
}
|
||||
else if (!loading) {
|
||||
const currentScope = scope
|
||||
loading = true
|
||||
loadThumbnailImage(url, scale, currentScope).then((loaded) => {
|
||||
if (scope !== currentScope || currentScope.closed)
|
||||
return
|
||||
loading = false
|
||||
image = loaded
|
||||
render()
|
||||
}).catch((error: unknown) => {
|
||||
if (scope === currentScope && !currentScope.closed) {
|
||||
loading = false
|
||||
console.warn('ArtPlayer thumbnail load failed:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
def(art, 'thumbnails', {
|
||||
get: () => art.option.thumbnails,
|
||||
set: (thumbnails: Thumbnails) => {
|
||||
if (!isClosing(art) && thumbnails.url && !art.option.isLive && !isClosing(art)) {
|
||||
art.option.thumbnails = thumbnails
|
||||
reset()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -50,8 +50,7 @@ export default function urlMix<Media extends SourceMedia, Host extends UrlHost<M
|
||||
void Promise.resolve(result).catch(error => failSource(operation, error))
|
||||
}
|
||||
else {
|
||||
if (oldUrl)
|
||||
URL.revokeObjectURL(oldUrl)
|
||||
// Media URLs are supplied by callers or adapters, which own revocation.
|
||||
if (!operation.active())
|
||||
return
|
||||
$video.src = newUrl
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SwitchHost } from './types'
|
||||
import { positionRevision } from '../media/position-revision'
|
||||
|
||||
// Native media clocks may round within a frame. Larger misses need one correction.
|
||||
const POSITION_TOLERANCE = 0.05
|
||||
|
||||
export function positionRestoration(art: Pick<SwitchHost, 'currentTime' | 'template'>, target: number, active: () => boolean) {
|
||||
let expected: number | undefined
|
||||
let observedSeek = false
|
||||
let corrected = false
|
||||
let manual = false
|
||||
let revision = positionRevision(art)
|
||||
const manualPosition = () => manual || positionRevision(art) !== revision
|
||||
const seeking = () => !!art.template?.$video?.seeking
|
||||
return {
|
||||
restore(): void {
|
||||
if (!active() || manualPosition())
|
||||
return
|
||||
const previousRevision = positionRevision(art)
|
||||
art.currentTime = target
|
||||
revision = positionRevision(art)
|
||||
// Nested public writes supersede the restoration's own setter call.
|
||||
if (revision > previousRevision + 1)
|
||||
manual = true
|
||||
if (!active())
|
||||
return
|
||||
// Keep the actual clamped value accepted by the player setter.
|
||||
expected = art.currentTime
|
||||
if (!active() || manualPosition())
|
||||
return
|
||||
observedSeek = seeking()
|
||||
},
|
||||
manual(): void {
|
||||
manual = true
|
||||
},
|
||||
ready(): boolean {
|
||||
if (!active() || seeking())
|
||||
return false
|
||||
const missed = expected !== undefined && Math.abs(art.currentTime - expected) > POSITION_TOLERANCE
|
||||
if (!manualPosition() && observedSeek && !corrected && expected !== undefined && missed) {
|
||||
corrected = true
|
||||
if (!active())
|
||||
return false
|
||||
art.currentTime = expected
|
||||
}
|
||||
return active() && !seeking()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { SourceEvent, SourceListener, SwitchHost } from './types'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { listenSource } from './listen'
|
||||
import { assignUrl, beginSource } from './operation'
|
||||
import { positionRestoration } from './restore-position'
|
||||
|
||||
export function switchSource(art: SwitchHost, url: string, currentTime: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -39,44 +40,67 @@ export function switchSource(art: SwitchHost, url: string, currentTime: number):
|
||||
return
|
||||
|
||||
const readiness = scope.child()
|
||||
let canPlay = false
|
||||
let resuming = false
|
||||
let rateRestored = false
|
||||
const position = positionRestoration(art, currentTime, active)
|
||||
const resume = async () => {
|
||||
art.aspectRatio = aspectRatio
|
||||
if (!active())
|
||||
return
|
||||
if (playing) {
|
||||
// The public play promise still rejects; this internal resume is best effort.
|
||||
try {
|
||||
await art.play()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
if (!active())
|
||||
return
|
||||
art.notice.show = ''
|
||||
settle()
|
||||
}
|
||||
const resumeWhenReady = () => {
|
||||
if (!active() || resuming || !canPlay)
|
||||
return
|
||||
try {
|
||||
// canplay can arrive before the metadata restoration seek has settled.
|
||||
if (art.template?.$video?.seeking)
|
||||
return
|
||||
if (!active())
|
||||
return
|
||||
if (!rateRestored) {
|
||||
rateRestored = true
|
||||
art.playbackRate = playbackRate
|
||||
}
|
||||
if (!position.ready() || !active() || resuming)
|
||||
return
|
||||
resuming = true
|
||||
readiness.dispose()
|
||||
void resume().catch(fail)
|
||||
}
|
||||
catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
const handlers = {
|
||||
'video:error': fail,
|
||||
'video:loadedmetadata': () => {
|
||||
if (!active())
|
||||
return
|
||||
try {
|
||||
art.currentTime = currentTime
|
||||
position.restore()
|
||||
}
|
||||
catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
},
|
||||
'video:canplay': () => {
|
||||
if (!active())
|
||||
return
|
||||
// Detach readiness handlers before awaiting a possibly pending play.
|
||||
readiness.dispose()
|
||||
const resume = async () => {
|
||||
art.playbackRate = playbackRate
|
||||
if (!active())
|
||||
return
|
||||
art.aspectRatio = aspectRatio
|
||||
if (!active())
|
||||
return
|
||||
if (playing) {
|
||||
// The public play promise still rejects; this internal resume is best effort.
|
||||
try {
|
||||
await art.play()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
if (!active())
|
||||
return
|
||||
art.notice.show = ''
|
||||
settle()
|
||||
}
|
||||
void resume().catch(fail)
|
||||
canPlay = true
|
||||
resumeWhenReady()
|
||||
},
|
||||
'video:seeked': resumeWhenReady,
|
||||
'seek': position.manual,
|
||||
}
|
||||
const capture = scope.child()
|
||||
const queued: [SourceEvent, unknown][] = []
|
||||
@@ -101,7 +125,7 @@ export function switchSource(art: SwitchHost, url: string, currentTime: number):
|
||||
}
|
||||
const dispatch = {} as Record<SourceEvent, SourceListener>
|
||||
for (const name of names)
|
||||
dispatch[name] = listenSource(readiness, art, name, handlers[name])
|
||||
dispatch[name] = listenSource(readiness, art, name, handlers[name], name !== 'video:seeked')
|
||||
for (const [name, event] of queued) {
|
||||
if (!active() || readiness.closed)
|
||||
break
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay'
|
||||
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay' | 'video:seeked' | 'seek'
|
||||
export type SourceListener = (event?: unknown) => void
|
||||
|
||||
export interface SourceEvents {
|
||||
@@ -28,6 +28,7 @@ export interface UrlHost<Media extends SourceMedia, Host> extends UrlTarget, Sou
|
||||
}
|
||||
|
||||
export interface SwitchHost extends UrlTarget, SourceEvents {
|
||||
template?: { $video?: { readonly seeking?: boolean } }
|
||||
currentTime: number
|
||||
readonly playing: boolean
|
||||
aspectRatio: string
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Thumbnails } from '../../types/option'
|
||||
|
||||
export interface ThumbnailGeometry {
|
||||
imageWidth: number
|
||||
videoWidth: number
|
||||
videoHeight: number
|
||||
progressWidth: number
|
||||
position: number
|
||||
}
|
||||
|
||||
export function thumbnailLayout(option: Thumbnails, geometry: ThumbnailGeometry) {
|
||||
const { number, column, width, height, scale } = option
|
||||
const { imageWidth, videoWidth, videoHeight, progressWidth, position } = geometry
|
||||
const columns = Number(column)
|
||||
const previewWidth = Number(width) * Number(scale) || imageWidth / columns
|
||||
const previewHeight = Number(height) * Number(scale) || previewWidth / (videoWidth / videoHeight)
|
||||
const index = Math.floor(position / (progressWidth / Number(number)))
|
||||
// Match the zero-based cells produced by artplayer-tool-thumbnail.
|
||||
const row = Math.floor(index / columns)
|
||||
const cell = index % columns
|
||||
const left = position <= previewWidth / 2
|
||||
? 0
|
||||
: position > progressWidth - previewWidth / 2
|
||||
? `${progressWidth - previewWidth}px`
|
||||
: `${position - previewWidth / 2}px`
|
||||
return {
|
||||
height: `${previewHeight}px`,
|
||||
width: `${previewWidth}px`,
|
||||
backgroundPosition: `-${cell * previewWidth}px -${row * previewHeight}px`,
|
||||
left,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isMobile } from './compatibility'
|
||||
|
||||
export { loadImg } from '../image/load'
|
||||
export { setStyleText } from '../style/inject'
|
||||
|
||||
/** @param {string} selector @param {ParentNode} parent */
|
||||
@@ -112,47 +113,6 @@ export function getRect(el) {
|
||||
return el.getBoundingClientRect()
|
||||
}
|
||||
|
||||
export function loadImg(url, scale) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = function () {
|
||||
if (!scale || scale === 1) {
|
||||
resolve(img)
|
||||
}
|
||||
else {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = img.width * scale
|
||||
canvas.height = img.height * scale
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const scaledImg = new Image()
|
||||
|
||||
scaledImg.onload = function () {
|
||||
resolve(scaledImg)
|
||||
}
|
||||
|
||||
scaledImg.onerror = function () {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
reject(new Error(`Image load failed: ${url}`))
|
||||
}
|
||||
|
||||
scaledImg.src = blobUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = function () {
|
||||
reject(new Error(`Image load failed: ${url}`))
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
export function getComposedPath(event) {
|
||||
if (event.composedPath)
|
||||
return event.composedPath()
|
||||
|
||||
Reference in new issue
Block a user