refactor(core): CORE-13 type component registries and own control lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 04:54:32 +08:00
1 parent 9eb7c945ca
commit 697817eceb
68 files changed
+10100 -1925

No files matched your search

+71 -3
View File
@@ -4,6 +4,74 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
The production entry is still `src/index.js`; this document marks actual migrated
boundaries rather than describing the entire core as TypeScript.
## Components and controls (CORE-13)
The public registries remain Component-based. utils/component.ts owns add/update/remove,
cache and dynamic name aliases; component/dom.ts owns insertion and known template
queries; component/types.ts defines the minimal generic host and callback shapes.
component/resources.ts owns each entry's child ResourceScope, DOM proxies and guarded
Emitter subscriptions. control/resources.ts specializes the shared subscriptions with
UIEvents; it does not create a second event bus. Builtins depend on these capabilities,
not the full player class. CORE-20 still has to integrate the constructor facade.
Control index.ts preserves routing and visibility behavior. builtins.ts preserves
installation order and option/platform conditions. selector.ts owns item binding,
selection rendering and asynchronous completion; progress.ts keeps the factory and
its historical helper exports while progress/position.ts, view.ts and interactions.ts
separate pointer math, rendering and drag ownership. The remaining simple controls
stay individual factories. layer.ts retains the generic registry; contextmenu/index.ts
owns positioning and root listeners, while its item factories own their own updates.
Preserve the observable registry rules: option factories are bare calls with art;
click/mounted/beforeUnmount use art as this; index 0 still follows the old index-or-id
rule; equal indexes insert before their predecessor. update mutates the cached option
before remove, so the newly supplied beforeUnmount runs on the old node. Component
add/update may return a div; Control add/update still return undefined. Source types
model that distinction; historical public declaration conflicts remain CORE-21 work.
A name of `__proto__` now creates a normal own DOM alias without replacing the registry
prototype. Other historical aliases and shadowing behavior are not broadly renamed.
Entry ownership starts before rendering. Failed mounting releases owned effects and
removes its DOM/cache/alias without masking the original exception or deleting a
successful reentrant replacement. beforeUnmount failure retains the entry for retry;
recursive removal of that same entry is ignored. Already detached DOM can be removed.
Builtin DOM/Emitter listeners, progress drag handlers and tip timers end on entry
removal/update or root destruction; closed handlers are inert even in an Emitter
snapshot. Whole-player destruction retains its existing cache/alias and user-hook
policy: it does not newly call every beforeUnmount. User mounted hook return values
remain ignored. Arbitrary user listeners and third-party SDK work still need explicit
user cleanup; internal scopes cannot infer their ownership.
Selector items keep their non-enumerable, non-configurable getter bindings. A WeakMap
allows the same items to bind to a replacement after the prior entry closes; sharing
items across simultaneously active controls remains invalid. Each click keeps original
item/node/Event/this arguments and synchronous default flags. Only the latest active
selection may write a delayed title; removal invalidates pending writes. Background
clicks are ignored and owned callback failures are warned with their original value.
component/selection.ts links a click's active state to the builtin quality callback,
so late source-switch completion cannot overwrite a newer or removed quality notice.
Direct callback invocations retain their Promise result and original rejection.
Deferred builtin quality installation reports errors without an unhandled Promise.
This does not cancel arbitrary user onSelect work or change switchQuality's public
settlement contract. Highlight text uses dataset assignment rather than interpolation
into HTML attributes; normal text and marker positions retain their behavior.
controls.less allows groups to wrap inside narrow players. Individual control height
continues to use --art-control-height; control/layout.ts observes total layout height
through offsetHeight and records --art-controls-height for subtitle/panel offsets.
ResizeObserver is preferred; the fallback combines core resize and MutationObserver.
Both observers and subscriptions are owned by the bottom controls scope. Without
ResizeObserver, unrelated parent CSS resizing is not independently detected until a
core resize or control mutation occurs. The 640/320/240px tests include real 16:9
heights and verify all buttons remain visible. This does not promise arbitrary custom
minimum widths will fit. Narrow setting-tree/panel behavior remains CORE-14 work.
Use test/component-resources.test.js for ownership, reentry and quality settlement,
test/types/components.ts for source contracts, and test/browser/components.spec.js
for published/candidate registries, real pointer/contextmenu controls, asynchronous
selectors, failures, actual layout and local-media progress. Run the full installed
UMD and legacy browser matrices after building, not only the source fixture suite.
## Template and public resources
`src/template.ts` owns container checks, reservation, proxy replacement and destruction.
@@ -29,7 +97,7 @@ uses the actual HTMLElement shape. This mismatch remains tracked for CORE-21.
`i18n/index.ts` owns selected language, deep updates and key fallback. Standalone
language modules keep default exports and artplayer-i18n-* aliases through publish.ts.
Only own language/message keys participate in lookup; absent prototype names now return
the requested string. Explicit custom constructor/toString/**proto** messages remain
the requested string. Explicit custom constructor/toString/`__proto__` messages remain
supported. Empty translations still fall back. build:i18n excludes built-in zh-cn and
helper modules, retaining exactly eleven UMD/ESM pairs and their historical globals.
@@ -182,7 +250,7 @@ throw stops that dispatch and propagates unchanged. Off accepts either a once wr
or its original callback; duplicate registrations of a callback are removed together.
The once wrapper unsubscribes before invoking user code and now records consumption,
so a nested dispatch cannot execute a captured copy twice. This fixes a demonstrated
old defect without changing ordinary snapshot behavior. Event names such as **proto**
old defect without changing ordinary snapshot behavior. Event names such as `__proto__`
and toString use own properties rather than inherited Object.prototype values.
Shared contracts in test/contracts/emitter.js run against published, workspace and
@@ -472,5 +540,5 @@ core files in docs/compiled when committing a shippable core change.
For new utility behavior, extend the same old/new contract tests. Clearly separate
intentional defect corrections from preserved behavior. For timers or Blob URLs,
also identify the owner and verify cleanup in the consuming module. Remaining
constructor, playback, UI, Component and capability migrations are recorded
constructor, playback, setting, input and capability migrations are recorded
in refactor/tasks.json and should extend this map as they land.
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
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,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+743 -536
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentHost, EntryOption } from './types'
import { addClass, append, setStyles, tooltip } from '../utils/dom'
export function renderEntry<Host extends ComponentHost>(element: HTMLDivElement, parent: HTMLElement, kind: string, name: string, id: number, option: EntryOption<Host>): void {
addClass(element, `art-${kind}`)
addClass(element, `art-${kind}-${name}`)
const children = Array.from(parent.children) as HTMLElement[]
element.dataset.index = String(option.index || id)
const next = children.find(child => Number(child.dataset.index) >= Number(element.dataset.index))
if (next)
next.insertAdjacentElement('beforebegin', element)
else
append(parent, element)
if (option.html)
append(element, option.html)
if (option.style)
setStyles(element, option.style)
if (option.tooltip)
tooltip(element, option.tooltip)
}
// Builtin renderers append known element markup or existing icon wrappers.
export function appendElement(parent: Element, child: string | Element): HTMLElement {
return append(parent, child) as HTMLElement
}
export function queryElement(selector: string, parent: ParentNode): HTMLElement {
return parent.querySelector<HTMLElement>(selector)!
}
@@ -0,0 +1,54 @@
import type ResourceScope from '../lifecycle/scope'
import type { ComponentHost } from './types'
import { getScope } from '../lifecycle/instance'
const scopes = new WeakMap<HTMLElement, ResourceScope>()
export function ownEntry(art: ComponentHost, element: HTMLElement): ResourceScope {
const scope = getScope(art).child()
scopes.set(element, scope)
return scope
}
export function entryScope(element: HTMLElement): ResourceScope {
const scope = scopes.get(element)
if (!scope)
throw new Error('ArtPlayer component has not been registered')
return scope
}
export function releaseEntry(element: HTMLElement): void {
scopes.get(element)?.dispose()
}
export function proxyEntry<Name extends keyof HTMLElementEventMap>(art: ComponentHost, element: HTMLElement, target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): () => void {
const scope = entryScope(element)
if (scope.closed)
return () => {}
const cleanup = art.events.proxy(target, name, (event) => {
if (!scope.closed)
return callback(event as HTMLElementEventMap[Name])
})
return scope.add(() => {
art.events.remove(cleanup)
})
}
export interface SubscriptionHost<Events extends { [Name in keyof Events]: readonly unknown[] }> {
on: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
off: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
}
export function subscribeEntry<Events extends { [Name in keyof Events]: readonly unknown[] }, Name extends keyof Events>(art: SubscriptionHost<Events>, element: HTMLElement, name: Name, callback: (...args: [...Events[Name]]) => unknown): void {
const scope = entryScope(element)
if (scope.closed)
return
const guarded = (...args: [...Events[Name]]) => {
if (!scope.closed)
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
@@ -0,0 +1,15 @@
import type { Cleanup } from '../lifecycle/scope'
const selections = new WeakMap<Event, () => boolean>()
export function trackSelection(event: Event, active: () => boolean): Cleanup {
selections.set(event, active)
return () => {
if (selections.get(event) === active)
selections.delete(event)
}
}
export function captureSelection(event?: Event): () => boolean {
return (event && selections.get(event)) || (() => true)
}
+44
View File
@@ -0,0 +1,44 @@
import type Component from '../utils/component'
export type EventCleanup = () => void
export interface ComponentHost {
template: { $player: HTMLElement }
emit: (name: string, ...args: unknown[]) => unknown
events: {
proxy: (target: EventTarget, name: string, callback: (event: Event) => unknown) => EventCleanup
remove: (cleanup: EventCleanup) => void
}
}
export interface SelectorItem {
html: string | HTMLElement | number
value?: string | number
default?: boolean
readonly $control_option?: SelectorItem[]
readonly $control_item?: HTMLDivElement
readonly $control_value?: HTMLDivElement
}
export interface EntryOption<Host extends ComponentHost> {
html?: string | HTMLElement | number
disable?: boolean
name?: string
index?: number
style?: Partial<CSSStyleDeclaration>
tooltip?: string | number
position?: string
selector?: SelectorItem[]
click?: (this: Host, component: Component<Host>, event: Event) => unknown
mounted?: (this: Host, element: HTMLDivElement) => unknown
beforeUnmount?: (this: Host, element: HTMLDivElement) => unknown
onSelect?: (this: Host, item: SelectorItem, element: HTMLDivElement, event: Event) => unknown
}
export type EntryInput<Host extends ComponentHost> = EntryOption<Host> | ((art: Host) => EntryOption<Host>)
export interface Entry<Host extends ComponentHost> {
$ref: HTMLDivElement
events: EventCleanup[]
option: EntryOption<Host>
}
@@ -1,6 +1,8 @@
import { inverseClass, query, queryAll } from '../utils'
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
export default function aspectRatio(option) {
export default function aspectRatio(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
const {
i18n,
@@ -15,19 +17,20 @@ export default function aspectRatio(option) {
...option,
html: `${i18n.get('Aspect Ratio')}: ${html}`,
click: (contextmenu, event) => {
const { value } = event.target.dataset
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
art.aspectRatio = value
contextmenu.show = false
}
},
mounted: ($panel) => {
const { on } = controlEvents(art, $panel)
const $default = query('[data-value="default"]', $panel)
if ($default) {
inverseClass($default, 'art-current')
}
art.on('aspectRatio', (value) => {
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
on('aspectRatio', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
}
@@ -1,9 +0,0 @@
export default function close(option) {
return art => ({
...option,
html: art.i18n.get('Close'),
click: (contextmenu) => {
contextmenu.show = false
},
})
}
@@ -0,0 +1,11 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
export default function close(option: ContextmenuOption): ContextmenuFactory {
return art => ({
...option,
html: art.i18n.get('Close'),
click: (contextmenu) => {
contextmenu.show = false
},
})
}
@@ -1,6 +1,8 @@
import { capitalize, inverseClass, query, queryAll } from '../utils'
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { capitalize, inverseClass, query } from '../utils'
export default function flip(option) {
export default function flip(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
const {
i18n,
@@ -13,19 +15,20 @@ export default function flip(option) {
...option,
html: `${i18n.get('Video Flip')}: ${html}`,
click: (contextmenu, event) => {
const { value } = event.target.dataset
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
art.flip = value.toLowerCase()
contextmenu.show = false
}
},
mounted: ($panel) => {
const { on } = controlEvents(art, $panel)
const $default = query('[data-value="normal"]', $panel)
if ($default) {
inverseClass($default, 'art-current')
}
art.on('flip', (value) => {
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
on('flip', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
}
@@ -1,3 +1,6 @@
import type { ContextmenuHost } from './types'
import { ownEntry } from '../component/resources'
import { controlEvents } from '../control/resources'
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
@@ -7,24 +10,25 @@ import info from './info'
import playbackRate from './playbackRate'
import version from './version'
export default class Contextmenu extends Component {
constructor(art) {
export default class Contextmenu extends Component<ContextmenuHost> {
constructor(art: ContextmenuHost) {
super(art)
this.name = 'contextmenu'
this.$parent = art.template.$contextmenu
ownEntry(art, this.$parent)
if (!isMobile) {
this.init()
}
}
init() {
init(): void {
const {
option,
proxy,
template: { $player, $contextmenu },
} = this.art
const { on, proxy } = controlEvents(this.art, $contextmenu)
if (option.playbackRate) {
this.add(
@@ -75,7 +79,7 @@ export default class Contextmenu extends Component {
)
for (let index = 0; index < option.contextmenu.length; index++) {
this.add(option.contextmenu[index])
this.add(option.contextmenu[index]!)
}
proxy($player, 'contextmenu', (event) => {
@@ -112,7 +116,7 @@ export default class Contextmenu extends Component {
}
})
this.art.on('blur', () => {
on('blur', () => {
this.show = false
})
}
@@ -1,4 +1,6 @@
export default function info(option) {
import type { ContextmenuFactory, ContextmenuOption } from './types'
export default function info(option: ContextmenuOption): ContextmenuFactory {
return art => ({
...option,
html: art.i18n.get('Video Info'),
@@ -1,6 +1,8 @@
import { inverseClass, query, queryAll } from '../utils'
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
export default function playbackRate(option) {
export default function playbackRate(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
const {
i18n,
@@ -15,18 +17,19 @@ export default function playbackRate(option) {
...option,
html: `${i18n.get('Play Speed')}: ${html}`,
click: (contextmenu, event) => {
const { value } = event.target.dataset
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
art.playbackRate = Number(value)
contextmenu.show = false
}
},
mounted: ($panel) => {
const { on } = controlEvents(art, $panel)
const $default = query('[data-value="1"]', $panel)
if ($default)
inverseClass($default, 'art-current')
art.on('video:ratechange', () => {
const $current = queryAll('span', $panel).find(
on('video:ratechange', () => {
const $current = Array.from($panel.querySelectorAll('span')).find(
item => Number(item.dataset.value) === art.playbackRate,
)
if ($current) {
@@ -0,0 +1,17 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
import type { UIEvents } from '../control/types'
export interface ContextmenuHost extends ComponentHost, SubscriptionHost<UIEvents> {
template: { $player: HTMLDivElement, $contextmenu: HTMLDivElement }
option: { playbackRate: boolean, aspectRatio: boolean, flip: boolean, contextmenu: EntryInput<ContextmenuHost>[] }
constructor: { CONTEXTMENU: boolean, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
i18n: { get: (key: string) => string }
info: { show: boolean }
playbackRate: number
aspectRatio: string
flip: string
}
export type ContextmenuOption = EntryOption<ContextmenuHost>
export type ContextmenuFactory = (art: ContextmenuHost) => ContextmenuOption
@@ -1,6 +1,7 @@
import type { ContextmenuOption } from './types'
import { version } from '../../package.json'
export default function (option) {
export default function (option: ContextmenuOption): ContextmenuOption {
return {
...option,
html: `<a href="https://artplayer.org" target="_blank" style="width:100%;">ArtPlayer ${version}</a>`,
-13
View File
@@ -1,13 +0,0 @@
import { append } from '../utils'
export default function airplay(option) {
return art => ({
...option,
tooltip: art.i18n.get('AirPlay'),
mounted: ($control) => {
const { proxy, icons } = art
append($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
})
}
+16
View File
@@ -0,0 +1,16 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { controlEvents } from './resources'
export default function airplay(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('AirPlay'),
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
appendElement($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
})
}
+135
View File
@@ -0,0 +1,135 @@
import type { EntryInput } from '../component/types'
import type { ControlHost } from './types'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { isMobile } from '../utils'
import airplay from './airplay'
import fullscreen from './fullscreen'
import fullscreenWeb from './fullscreenWeb'
import pip from './pip'
import playAndPause from './playAndPause'
import progress from './progress'
import screenshot from './screenshot'
import setting from './setting'
import time from './time'
import volume from './volume'
export function installControls(controls: { art: ControlHost, add: (option: EntryInput<ControlHost>) => undefined }): void {
const { option } = controls.art
if (!option.isLive) {
controls.add(
progress({
name: 'progress',
position: 'top',
index: 10,
}),
)
}
controls.add({
name: 'thumbnails',
position: 'top',
index: 20,
})
controls.add(
playAndPause({
name: 'playAndPause',
position: 'left',
index: 10,
}),
)
controls.add(
volume({
name: 'volume',
position: 'left',
index: 20,
}),
)
if (!option.isLive) {
controls.add(
time({
name: 'time',
position: 'left',
index: 30,
}),
)
}
if (option.quality.length) {
wait(getScope(controls.art)).then((active) => {
if (!active || getScope(controls.art).closed)
return
controls.art.quality = option.quality
}).catch((error) => {
console.warn('ArtPlayer quality initialization failed:', error)
})
}
if (option.screenshot && !isMobile) {
controls.add(
screenshot({
name: 'screenshot',
position: 'right',
index: 20,
}),
)
}
if (option.setting) {
controls.add(
setting({
name: 'setting',
position: 'right',
index: 30,
}),
)
}
if (option.pip) {
controls.add(
pip({
name: 'pip',
position: 'right',
index: 40,
}),
)
}
if (option.airplay && 'WebKitPlaybackTargetAvailabilityEvent' in window && window.WebKitPlaybackTargetAvailabilityEvent) {
controls.add(
airplay({
name: 'airplay',
position: 'right',
index: 50,
}),
)
}
if (option.fullscreenWeb) {
controls.add(
fullscreenWeb({
name: 'fullscreenWeb',
position: 'right',
index: 60,
}),
)
}
if (option.fullscreen) {
controls.add(
fullscreen({
name: 'fullscreen',
position: 'right',
index: 70,
}),
)
}
for (let index = 0; index < option.controls.length; index++) {
controls.add(option.controls[index]!)
}
}
@@ -1,21 +1,25 @@
import { append, setStyle, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function fullscreen(option) {
export default function fullscreen(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Fullscreen'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $fullscreenOn = append($control, icons.fullscreenOn)
const $fullscreenOff = append($control, icons.fullscreenOff)
const $fullscreenOn = appendElement($control, icons.fullscreenOn)
const $fullscreenOff = appendElement($control, icons.fullscreenOff)
setStyle($fullscreenOff, 'display', 'none')
proxy($control, 'click', () => {
art.fullscreen = !art.fullscreen
})
art.on('fullscreen', (state) => {
on('fullscreen', (state) => {
if (state) {
tooltip($control, i18n.get('Exit Fullscreen'))
setStyle($fullscreenOn, 'display', 'none')
@@ -1,21 +1,25 @@
import { append, setStyle, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function fullscreenWeb(option) {
export default function fullscreenWeb(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Web Fullscreen'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $fullscreenWebOn = append($control, icons.fullscreenWebOn)
const $fullscreenWebOff = append($control, icons.fullscreenWebOff)
const $fullscreenWebOn = appendElement($control, icons.fullscreenWebOn)
const $fullscreenWebOff = appendElement($control, icons.fullscreenWebOff)
setStyle($fullscreenWebOff, 'display', 'none')
proxy($control, 'click', () => {
art.fullscreenWeb = !art.fullscreenWeb
})
art.on('fullscreenWeb', (value) => {
on('fullscreenWeb', (value) => {
if (value) {
tooltip($control, i18n.get('Exit Web Fullscreen'))
setStyle($fullscreenWebOn, 'display', 'none')
-290
View File
@@ -1,290 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import {
addClass,
append,
createElement,
def,
errorHandle,
getComposedPath,
includeFromEvent,
inverseClass,
isMobile,
removeClass,
} from '../utils'
import Component from '../utils/component'
import airplay from './airplay'
import fullscreen from './fullscreen'
import fullscreenWeb from './fullscreenWeb'
import pip from './pip'
import playAndPause from './playAndPause'
import progress from './progress'
import screenshot from './screenshot'
import setting from './setting'
import time from './time'
import volume from './volume'
export default class Control extends Component {
constructor(art) {
super(art)
this.isHover = false
this.name = 'control'
this.timer = Date.now()
const { constructor } = art
const { $player, $bottom } = this.art.template
art.on('mousemove', () => {
if (!isMobile) {
this.show = true
}
})
art.on('click', () => {
if (isMobile) {
this.toggle()
}
else {
this.show = true
}
})
art.on('document:mousemove', (event) => {
this.isHover = includeFromEvent(event, $bottom)
})
art.on('video:timeupdate', () => {
if (
!art.setting.show
&& !this.isHover
&& !art.isInput
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
) {
this.show = false
}
})
art.on('control', (state) => {
if (state) {
removeClass($player, 'art-hide-cursor')
addClass($player, 'art-hover')
this.timer = Date.now()
}
else {
addClass($player, 'art-hide-cursor')
removeClass($player, 'art-hover')
}
})
this.init()
}
init() {
const { option } = this.art
if (!option.isLive) {
this.add(
progress({
name: 'progress',
position: 'top',
index: 10,
}),
)
}
this.add({
name: 'thumbnails',
position: 'top',
index: 20,
})
this.add(
playAndPause({
name: 'playAndPause',
position: 'left',
index: 10,
}),
)
this.add(
volume({
name: 'volume',
position: 'left',
index: 20,
}),
)
if (!option.isLive) {
this.add(
time({
name: 'time',
position: 'left',
index: 30,
}),
)
}
if (option.quality.length) {
wait(getScope(this.art)).then((active) => {
if (!active || getScope(this.art).closed)
return
this.art.quality = option.quality
})
}
if (option.screenshot && !isMobile) {
this.add(
screenshot({
name: 'screenshot',
position: 'right',
index: 20,
}),
)
}
if (option.setting) {
this.add(
setting({
name: 'setting',
position: 'right',
index: 30,
}),
)
}
if (option.pip) {
this.add(
pip({
name: 'pip',
position: 'right',
index: 40,
}),
)
}
if (option.airplay && window.WebKitPlaybackTargetAvailabilityEvent) {
this.add(
airplay({
name: 'airplay',
position: 'right',
index: 50,
}),
)
}
if (option.fullscreenWeb) {
this.add(
fullscreenWeb({
name: 'fullscreenWeb',
position: 'right',
index: 60,
}),
)
}
if (option.fullscreen) {
this.add(
fullscreen({
name: 'fullscreen',
position: 'right',
index: 70,
}),
)
}
for (let index = 0; index < option.controls.length; index++) {
this.add(option.controls[index])
}
}
add(getOption) {
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
const { $progress, $controlsLeft, $controlsRight } = this.art.template
switch (option.position) {
case 'top':
this.$parent = $progress
break
case 'left':
this.$parent = $controlsLeft
break
case 'right':
this.$parent = $controlsRight
break
default:
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
break
}
super.add(option)
}
check(target) {
if (!target) {
return
}
target.$control_value.innerHTML = target.html
for (let index = 0; index < target.$control_option.length; index++) {
const item = target.$control_option[index]
item.default = item === target
if (item.default) {
inverseClass(item.$control_item, 'art-current')
}
}
}
selector(option, $ref, events) {
const { proxy } = this.art.events
addClass($ref, 'art-control-selector')
const $value = createElement('div')
addClass($value, 'art-selector-value')
append($value, option.html)
$ref.textContent = ''
append($ref, $value)
const $list = createElement('div')
addClass($list, 'art-selector-list')
append($ref, $list)
for (let index = 0; index < option.selector.length; index++) {
const item = option.selector[index]
const $item = createElement('div')
addClass($item, 'art-selector-item')
if (item.default)
addClass($item, 'art-current')
$item.dataset.index = index
$item.dataset.value = item.value
$item.innerHTML = item.html
append($list, $item)
def(item, '$control_option', {
get: () => option.selector,
})
def(item, '$control_item', {
get: () => $item,
})
def(item, '$control_value', {
get: () => $value,
})
}
const event = proxy($list, 'click', async (event) => {
const path = getComposedPath(event)
const item = option.selector.find(
item => item.$control_item === path.find($item => item.$control_item === $item),
)
this.check(item)
if (option.onSelect) {
$value.innerHTML = await option.onSelect.call(this.art, item, item.$control_item, event)
}
})
events.push(event)
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { EntryInput, EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { ownEntry } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
import {
addClass,
errorHandle,
includeFromEvent,
isMobile,
removeClass,
} from '../utils'
import Component from '../utils/component'
import { installControls } from './builtins'
import { observeControlLayout } from './layout'
import { controlEvents } from './resources'
import { checkSelector, renderSelector } from './selector'
export default class Control extends Component<ControlHost> {
declare isHover: boolean
declare timer: number
constructor(art: ControlHost) {
super(art)
this.isHover = false
this.name = 'control'
this.timer = Date.now()
const { constructor } = art
const { $player, $bottom } = this.art.template
ownEntry(art, $bottom)
const { on } = controlEvents(art, $bottom)
on('mousemove', () => {
if (!isMobile) {
this.show = true
}
})
on('click', () => {
if (isMobile) {
this.toggle()
}
else {
this.show = true
}
})
on('document:mousemove', (event) => {
this.isHover = includeFromEvent(event, $bottom)
})
on('video:timeupdate', () => {
if (
!art.setting.show
&& !this.isHover
&& !art.isInput
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
) {
this.show = false
}
})
on('control', (state) => {
if (state) {
removeClass($player, 'art-hide-cursor')
addClass($player, 'art-hover')
this.timer = Date.now()
}
else {
addClass($player, 'art-hide-cursor')
removeClass($player, 'art-hover')
}
})
this.init()
if (!getScope(art).closed)
observeControlLayout(art)
}
init(): void {
installControls(this)
}
override add(getOption: EntryInput<ControlHost>): undefined {
if (isClosing(this.art))
return
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
const { $progress, $controlsLeft, $controlsRight } = this.art.template
switch (option.position) {
case 'top':
this.$parent = $progress
break
case 'left':
this.$parent = $controlsLeft
break
case 'right':
this.$parent = $controlsRight
break
default:
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
break
}
super.add(option)
}
check(target?: SelectorItem): void {
checkSelector(target)
}
override selector(option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
renderSelector(this.art, target => this.check(target), option, $ref, events)
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { ControlHost } from './types'
import { entryScope } from '../component/resources'
import { controlEvents } from './resources'
export function observeControlLayout(art: ControlHost): void {
const { $bottom, $controls, $player } = art.template
const scope = entryScope($bottom)
const { on } = controlEvents(art, $bottom)
const update = () => {
if (scope.closed)
return
// Layout height is unaffected by the player's rotation/scale transform.
const height = $controls.offsetHeight
if (height > 0 && $player.style.getPropertyValue('--art-controls-height') !== `${height}px`)
$player.style.setProperty('--art-controls-height', `${height}px`)
}
on('resize', update)
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(update)
observer.observe($controls)
scope.add(() => {
observer.disconnect()
})
}
else if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(update)
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
scope.add(() => {
observer.disconnect()
})
}
update()
}
-21
View File
@@ -1,21 +0,0 @@
import { append, tooltip } from '../utils'
export default function pip(option) {
return art => ({
...option,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
append($control, icons.pip)
proxy($control, 'click', () => {
art.pip = !art.pip
})
art.on('pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
}
+25
View File
@@ -0,0 +1,25 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
export default function pip(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
appendElement($control, icons.pip)
proxy($control, 'click', () => {
art.pip = !art.pip
})
on('pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
}
@@ -1,13 +1,17 @@
import { append, setStyle, silencePromise, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, silencePromise, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function playAndPause(option) {
export default function playAndPause(option: ControlOption): ControlFactory {
return art => ({
...option,
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $play = append($control, icons.play)
const $pause = append($control, icons.pause)
const $play = appendElement($control, icons.play)
const $pause = appendElement($control, icons.pause)
tooltip($play, i18n.get('Play'))
tooltip($pause, i18n.get('Pause'))
@@ -36,11 +40,11 @@ export default function playAndPause(option) {
showPlay()
}
art.on('video:playing', () => {
on('video:playing', () => {
showPause()
})
art.on('video:pause', () => {
on('video:pause', () => {
showPlay()
})
},
-208
View File
@@ -1,208 +0,0 @@
import { addClass, append, clamp, getRect, includeFromEvent, isMobile, query, removeClass, secondToTime, setStyle } from '../utils'
export function getPosFromEvent(art, event) {
const { $progress } = art.template
const { left } = getRect($progress)
const eventLeft = isMobile ? event.touches[0].clientX : event.clientX
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
const second = (width / $progress.clientWidth) * art.duration
const time = secondToTime(second)
const percentage = clamp(width / $progress.clientWidth, 0, 1)
return { second, time, width, percentage }
}
export function setCurrentTime(art, event) {
if (art.isRotate) {
const percentage = (event.touches[0].clientY - art.top) / art.height
const second = percentage * art.duration
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
}
export default function progress(options) {
return (art) => {
const { icons, option, proxy } = art
const { $player, $progress } = art.template
return {
...options,
html: `
<div class="art-control-progress-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
<div class="art-progress-highlight"></div>
<div class="art-progress-indicator"></div>
<div class="art-progress-tip">00:00</div>
</div>
`,
mounted: ($control) => {
let tipTimer = null
let isDragging = false
const $hover = query('.art-progress-hover', $control)
const $loaded = query('.art-progress-loaded', $control)
const $played = query('.art-progress-played', $control)
const $highlight = query('.art-progress-highlight', $control)
const $indicator = query('.art-progress-indicator', $control)
const $tip = query('.art-progress-tip', $control)
if (icons.indicator) {
append($indicator, icons.indicator)
}
else {
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
}
function showHighlight(event) {
const { width } = getPosFromEvent(art, event)
const { text } = event.target.dataset
$tip.textContent = text
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function showTime(event, touch) {
const { width, time } = touch || getPosFromEvent(art, event)
$tip.textContent = time || '00:00'
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function updateHighlight() {
$highlight.textContent = ''
for (let index = 0; index < option.highlight.length; index++) {
const item = option.highlight[index]
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
const html = `<span data-text="${item.text}" data-time="${item.time}" style="left: ${left}%"></span>`
append($highlight, html)
}
}
function setBar(type, percentage, event) {
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'loaded') {
setStyle($loaded, 'width', `${percentage * 100}%`)
}
if (type === 'hover') {
setStyle($hover, 'width', `${percentage * 100}%`)
if (includeFromEvent(event, $highlight)) {
showHighlight(event)
}
else {
showTime(event)
}
if (percentage === 0) {
removeClass($player, 'art-progress-hover')
}
else {
addClass($player, 'art-progress-hover')
}
}
if (type === 'played') {
setStyle($played, 'width', `${percentage * 100}%`)
setStyle($indicator, 'left', `${percentage * 100}%`)
}
if (isMobileDragging) {
addClass($player, 'art-progress-hover')
const width = $control.clientWidth * percentage
const time = secondToTime(percentage * art.duration)
showTime(event, { width, time })
clearTimeout(tipTimer)
tipTimer = setTimeout(() => {
removeClass($player, 'art-progress-hover')
}, 500)
}
}
art.on('setBar', setBar)
art.on('video:loadedmetadata', updateHighlight)
if (art.constructor.USE_RAF) {
art.on('raf', () => {
art.emit('setBar', 'played', art.played)
art.emit('setBar', 'loaded', art.loaded)
})
}
else {
art.on('video:timeupdate', () => {
art.emit('setBar', 'played', art.played)
})
art.on('video:progress', () => {
art.emit('setBar', 'loaded', art.loaded)
})
art.on('video:ended', () => {
art.emit('setBar', 'played', 1)
})
}
art.emit('setBar', 'loaded', art.loaded || 0)
if (!isMobile) {
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
}
})
proxy($progress, 'mousemove', (event) => {
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
art.emit('setBar', 'hover', 0, event)
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
})
art.on('document:mousemove', (event) => {
if (isDragging) {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
})
art.on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
})
}
},
}
}
}
@@ -0,0 +1,25 @@
import type { ControlFactory, ControlOption } from './types'
import { installProgressInteractions } from './progress/interactions'
import { mountProgressView } from './progress/view'
export { getPosFromEvent, setCurrentTime } from './progress/position'
export default function progress(options: ControlOption): ControlFactory {
return art => ({
...options,
html: `
<div class="art-control-progress-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
<div class="art-progress-highlight"></div>
<div class="art-progress-indicator"></div>
<div class="art-progress-tip">00:00</div>
</div>
`,
mounted: ($control) => {
mountProgressView(art, $control)
installProgressInteractions(art, $control)
},
})
}
@@ -0,0 +1,45 @@
import type { ControlHost } from '../types'
import { isMobile } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent, setCurrentTime } from './position'
export function installProgressInteractions(art: ControlHost, $control: HTMLDivElement): void {
const { $progress } = art.template
const $indicator = $control.querySelector('.art-progress-indicator')
const { on, proxy } = controlEvents(art, $control)
if (!isMobile) {
let isDragging = false
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
}
})
proxy($progress, 'mousemove', (event) => {
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
art.emit('setBar', 'hover', 0, event)
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
})
on('document:mousemove', (event) => {
if (isDragging) {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
})
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
})
}
}
@@ -0,0 +1,30 @@
import type { ControlHost } from '../types'
import { clamp, getRect, isMobile, secondToTime } from '../../utils'
export type ProgressEvent = MouseEvent | TouchEvent
type ProgressPositionHost = Pick<ControlHost, 'template' | 'duration' | 'isRotate' | 'top' | 'height' | 'emit' | 'seek'>
export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
const { $progress } = art.template
const { left } = getRect($progress)
const eventLeft = isMobile ? (event as TouchEvent).touches[0]!.clientX : (event as MouseEvent).clientX
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
const second = (width / $progress.clientWidth) * art.duration
const time = secondToTime(second)
const percentage = clamp(width / $progress.clientWidth, 0, 1)
return { second, time, width, percentage }
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
if (art.isRotate) {
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
const second = percentage * art.duration
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
}
@@ -0,0 +1,141 @@
import type { ControlHost } from '../types'
import type { ProgressEvent } from './position'
import { queryElement } from '../../component/dom'
import { entryScope } from '../../component/resources'
import { timeout } from '../../lifecycle/resources'
import { addClass, append, clamp, includeFromEvent, isMobile, removeClass, secondToTime, setStyle } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent } from './position'
export function mountProgressView(art: ControlHost, $control: HTMLDivElement): void {
const { icons, option } = art
const { $player } = art.template
const scope = entryScope($control)
const { on } = controlEvents(art, $control)
let cancelTip = () => {}
const $hover = queryElement('.art-progress-hover', $control)
const $loaded = queryElement('.art-progress-loaded', $control)
const $played = queryElement('.art-progress-played', $control)
const $highlight = queryElement('.art-progress-highlight', $control)
const $indicator = queryElement('.art-progress-indicator', $control)
const $tip = queryElement('.art-progress-tip', $control)
if (icons.indicator) {
append($indicator, icons.indicator)
}
else {
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
}
function showHighlight(event: ProgressEvent) {
const { width } = getPosFromEvent(art, event)
const text = event.target instanceof HTMLElement ? event.target.dataset.text : undefined
$tip.textContent = text ?? ''
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function showTime(event: ProgressEvent, touch?: { width: number, time: string }) {
const { width, time } = touch || getPosFromEvent(art, event)
$tip.textContent = time || '00:00'
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function updateHighlight() {
$highlight.textContent = ''
for (let index = 0; index < option.highlight.length; index++) {
const item = option.highlight[index]!
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
const marker = document.createElement('span')
marker.dataset.text = item.text
marker.dataset.time = String(item.time)
marker.style.left = `${left}%`
append($highlight, marker)
}
}
function setBar(type: string, percentage: number, event?: ProgressEvent) {
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'loaded') {
setStyle($loaded, 'width', `${percentage * 100}%`)
}
if (type === 'hover') {
setStyle($hover, 'width', `${percentage * 100}%`)
if (includeFromEvent(event!, $highlight)) {
showHighlight(event!)
}
else {
showTime(event!)
}
if (percentage === 0) {
removeClass($player, 'art-progress-hover')
}
else {
addClass($player, 'art-progress-hover')
}
}
if (type === 'played') {
setStyle($played, 'width', `${percentage * 100}%`)
setStyle($indicator, 'left', `${percentage * 100}%`)
}
if (isMobileDragging) {
addClass($player, 'art-progress-hover')
const width = $control.clientWidth * percentage
const time = secondToTime(percentage * art.duration)
showTime(event!, { width, time })
cancelTip()
cancelTip = timeout(scope, () => {
removeClass($player, 'art-progress-hover')
}, 500)
}
}
on('setBar', setBar)
on('video:loadedmetadata', updateHighlight)
if (art.constructor.USE_RAF) {
on('raf', () => {
art.emit('setBar', 'played', art.played)
art.emit('setBar', 'loaded', art.loaded)
})
}
else {
on('video:timeupdate', () => {
art.emit('setBar', 'played', art.played)
})
on('video:progress', () => {
art.emit('setBar', 'loaded', art.loaded)
})
on('video:ended', () => {
art.emit('setBar', 'played', 1)
})
}
art.emit('setBar', 'loaded', art.loaded || 0)
}
@@ -0,0 +1,13 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type { UIEvents } from './types'
import { proxyEntry, subscribeEntry } from '../component/resources'
export function controlEvents(art: ComponentHost & SubscriptionHost<UIEvents>, element: HTMLElement) {
return {
on: <Name extends keyof UIEvents>(name: Name, callback: (...args: [...UIEvents[Name]]) => unknown): void => {
subscribeEntry<UIEvents, Name>(art, element, name, callback)
},
proxy: <Name extends keyof HTMLElementEventMap>(target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): (() => void) => proxyEntry(art, element, target, name, callback),
}
}
@@ -1,16 +0,0 @@
import { append } from '../utils'
export default function screenshot(option) {
return art => ({
...option,
tooltip: art.i18n.get('Screenshot'),
mounted: ($control) => {
const { proxy, icons } = art
append($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
})
},
})
}
@@ -0,0 +1,19 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { controlEvents } from './resources'
export default function screenshot(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Screenshot'),
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
appendElement($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
})
},
})
}
@@ -0,0 +1,96 @@
import type { EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { trackSelection } from '../component/selection'
import { addClass, append, def, errorHandle, getComposedPath, inverseClass } from '../utils'
interface Binding {
option: EntryOption<ControlHost>
item: HTMLDivElement
value: HTMLDivElement
owner: HTMLDivElement
}
const bindings = new WeakMap<SelectorItem, Binding>()
function bind(item: SelectorItem, binding: Binding): void {
const previous = bindings.get(item)
errorHandle(!previous || entryScope(previous.owner).closed, 'Cannot share selector items between active controls')
if (!previous) {
def(item, '$control_option', { get: () => bindings.get(item)!.option.selector })
def(item, '$control_item', { get: () => bindings.get(item)!.item })
def(item, '$control_value', { get: () => bindings.get(item)!.value })
}
bindings.set(item, binding)
}
// WebIDL keeps legacy numeric/Element coercion and null-to-empty innerHTML behavior.
function setHTML(element: HTMLElement, value: unknown): void {
element.innerHTML = value as string
}
export function checkSelector(target?: SelectorItem): void {
if (!target)
return
setHTML(target.$control_value!, target.html)
for (let index = 0; index < target.$control_option!.length; index++) {
const item = target.$control_option![index]!
item.default = item === target
if (item.default)
inverseClass(item.$control_item, 'art-current')
}
}
export function renderSelector(art: ControlHost, check: (target?: SelectorItem) => void, option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
const { proxy } = art.events
const scope = entryScope($ref)
const selector = option.selector!
addClass($ref, 'art-control-selector')
const $value = document.createElement('div')
addClass($value, 'art-selector-value')
append($value, option.html)
$ref.textContent = ''
append($ref, $value)
const $list = appendElement($ref, '<div class="art-selector-list"></div>')
for (let index = 0; index < selector.length; index++) {
const item = selector[index]!
const $item = document.createElement('div')
addClass($item, 'art-selector-item')
if (item.default)
addClass($item, 'art-current')
$item.dataset.index = String(index)
$item.dataset.value = String(item.value)
setHTML($item, item.html)
append($list, $item)
bind(item, { option, item: $item, value: $value, owner: $ref })
}
let generation = 0
const event = proxy($list, 'click', async (event) => {
if (scope.closed)
return
const path = getComposedPath(event)
const item = option.selector!.find(item => path.includes(item.$control_item))
if (!item)
return
const current = ++generation
const active = () => !scope.closed && current === generation
const release = scope.add(trackSelection(event, active))
try {
check(item)
if (scope.closed)
return
if (option.onSelect) {
const value = await option.onSelect.call(art, item, item.$control_item!, event)
if (active())
setHTML($value, value)
}
}
catch (error) {
console.warn('ArtPlayer selector failed:', error)
}
finally {
release()
}
})
events.push(event)
}
-22
View File
@@ -1,22 +0,0 @@
import { append, tooltip } from '../utils'
export default function setting(option) {
return art => ({
...option,
tooltip: art.i18n.get('Show Setting'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
append($control, icons.setting)
proxy($control, 'click', () => {
art.setting.toggle()
art.setting.resize()
})
art.on('setting', (value) => {
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
export default function setting(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Show Setting'),
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
appendElement($control, icons.setting)
proxy($control, 'click', () => {
art.setting.toggle()
art.setting.resize()
})
on('setting', (value) => {
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
})
}
@@ -1,6 +1,8 @@
import type { ControlFactory, ControlOption } from './types'
import { isMobile, secondToTime } from '../utils'
import { controlEvents } from './resources'
export default function time(option) {
export default function time(option: ControlOption): ControlFactory {
return art => ({
...option,
style: isMobile
@@ -13,6 +15,7 @@ export default function time(option) {
padding: '0 10px',
},
mounted: ($control) => {
const { on } = controlEvents(art, $control)
function getTime() {
const newTime = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
if (newTime !== $control.textContent) {
@@ -22,9 +25,9 @@ export default function time(option) {
getTime()
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress']
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress'] as const
for (let index = 0; index < events.length; index++) {
art.on(events[index], getTime)
on(events[index]!, getTime)
}
},
})
+82
View File
@@ -0,0 +1,82 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
export interface UIEvents {
'mousemove': [MouseEvent]
'click': [MouseEvent]
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
'video:timeupdate': [Event]
'video:loadedmetadata': [Event]
'video:progress': [Event]
'video:ended': [Event]
'video:playing': [Event]
'video:pause': [Event]
'video:volumechange': [Event]
'video:ratechange': [Event]
'setBar': [type: string, percentage: number, event?: MouseEvent | TouchEvent]
'raf': []
'control': [boolean]
'fullscreen': [boolean]
'fullscreenWeb': [boolean]
'pip': [boolean]
'setting': [boolean]
'aspectRatio': [string]
'flip': [string]
'blur': []
'resize': []
}
export interface QualityItem { html: string, url: string, default?: boolean }
type IconName = 'play' | 'pause' | 'volume' | 'volumeClose' | 'fullscreenOn' | 'fullscreenOff' | 'fullscreenWebOn' | 'fullscreenWebOff' | 'pip' | 'setting' | 'screenshot' | 'airplay'
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents> {
template: {
$player: HTMLDivElement
$bottom: HTMLDivElement
$controls: HTMLDivElement
$progress: HTMLDivElement
$controlsLeft: HTMLDivElement
$controlsRight: HTMLDivElement
}
option: {
isLive: boolean
quality: QualityItem[]
screenshot: boolean
setting: boolean
pip: boolean
airplay: boolean
fullscreenWeb: boolean
fullscreen: boolean
controls: EntryInput<ControlHost>[]
highlight: { time: number, text: string }[]
}
icons: Record<IconName, HTMLElement> & { indicator?: HTMLElement }
i18n: { get: (key: string) => string }
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean }
setting: { show: boolean, toggle: () => void, resize: () => void }
isInput: boolean
isRotate: boolean
playing: boolean
muted: boolean
volume: number
currentTime: number
duration: number
played: number
loaded: number
top: number
height: number
fullscreen: boolean
fullscreenWeb: boolean
pip: boolean
set seek(second: number)
set quality(value: QualityItem[])
play: () => unknown
pause: () => unknown
screenshot: () => unknown
airplay: () => unknown
}
export type ControlOption = EntryOption<ControlHost>
export type ControlFactory = (art: ControlHost) => ControlOption
@@ -1,22 +1,26 @@
import { append, getRect, isMobile, setStyle } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { getRect, isMobile, setStyle } from '../utils'
import { controlEvents } from './resources'
export default function volume(option) {
export default function volume(option: ControlOption): ControlFactory {
return art => ({
...option,
mounted: ($control) => {
const { proxy, icons } = art
const { on, proxy } = controlEvents(art, $control)
const { icons } = art
const $volume = append($control, icons.volume)
const $close = append($control, icons.volumeClose)
const $panel = append($control, '<div class="art-volume-panel"></div>')
const $inner = append($panel, '<div class="art-volume-inner"></div>')
const $value = append($inner, `<div class="art-volume-val"></div>`)
const $slider = append($inner, `<div class="art-volume-slider"></div>`)
const $handle = append($slider, `<div class="art-volume-handle"></div>`)
const $loaded = append($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = append($slider, `<div class="art-volume-indicator"></div>`)
const $volume = appendElement($control, icons.volume)
const $close = appendElement($control, icons.volumeClose)
const $panel = appendElement($control, '<div class="art-volume-panel"></div>')
const $inner = appendElement($panel, '<div class="art-volume-inner"></div>')
const $value = appendElement($inner, `<div class="art-volume-val"></div>`)
const $slider = appendElement($inner, `<div class="art-volume-slider"></div>`)
const $handle = appendElement($slider, `<div class="art-volume-handle"></div>`)
const $loaded = appendElement($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = appendElement($slider, `<div class="art-volume-indicator"></div>`)
function getVolumeFromEvent(event) {
function getVolumeFromEvent(event: MouseEvent) {
const { top, height } = getRect($slider)
return 1 - (event.clientY - top) / height
}
@@ -27,7 +31,7 @@ export default function volume(option) {
setStyle($close, 'display', 'flex')
setStyle($indicator, 'top', '100%')
setStyle($loaded, 'top', '100%')
$value.textContent = 0
$value.textContent = '0'
}
else {
const percentage = art.volume * 100
@@ -35,12 +39,12 @@ export default function volume(option) {
setStyle($close, 'display', 'none')
setStyle($indicator, 'top', `${100 - percentage}%`)
setStyle($loaded, 'top', `${100 - percentage}%`)
$value.textContent = Math.floor(percentage)
$value.textContent = String(Math.floor(percentage))
}
}
update()
art.on('video:volumechange', update)
on('video:volumechange', update)
proxy($volume, 'click', () => {
art.muted = true
@@ -61,14 +65,14 @@ export default function volume(option) {
art.volume = getVolumeFromEvent(event)
})
art.on('document:mousemove', (event) => {
on('document:mousemove', (event) => {
if (isDragging) {
art.muted = false
art.volume = getVolumeFromEvent(event)
}
})
art.on('document:mouseup', () => {
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
-19
View File
@@ -1,19 +0,0 @@
import Component from './utils/component'
export default class Layer extends Component {
constructor(art) {
super(art)
const {
option,
template: { $layer },
} = art
this.name = 'layer'
this.$parent = $layer
for (let index = 0; index < option.layers.length; index++) {
this.add(option.layers[index])
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { ComponentHost, EntryInput } from './component/types'
import Component from './utils/component'
export interface LayerHost extends ComponentHost {
option: { layers: EntryInput<LayerHost>[] }
template: { $player: HTMLElement, $layer: HTMLElement }
}
export default class Layer extends Component<LayerHost> {
constructor(art: LayerHost) {
super(art)
const { option, template: { $layer } } = art
this.name = 'layer'
this.$parent = $layer
for (let index = 0; index < option.layers.length; index++)
this.add(option.layers[index]!)
}
}
@@ -1,25 +0,0 @@
import { def } from '../utils'
export default function qualityMix(art) {
def(art, 'quality', {
set(quality) {
const { controls, notice, i18n } = art
const qualityDefault = quality.find(item => item.default) || quality[0]
controls.update({
name: 'quality',
position: 'right',
index: 10,
style: {
marginRight: '10px',
},
html: qualityDefault?.html || '',
selector: quality,
async onSelect(item) {
await art.switchQuality(item.url)
notice.show = `${i18n.get('Switch Video')}: ${item.html}`
return item.html
},
})
},
})
}
@@ -0,0 +1,40 @@
import type { ComponentHost, EntryOption } from '../component/types'
import type { QualityItem } from '../control/types'
import { captureSelection } from '../component/selection'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export interface QualityHost extends ComponentHost {
controls: { update: (option: EntryOption<QualityHost>) => unknown }
notice: { set show(message: string) }
i18n: { get: (key: string) => string }
switchQuality: (url: string) => Promise<unknown>
}
export default function qualityMix(art: QualityHost): void {
def(art, 'quality', {
set(quality: QualityItem[]) {
const { controls, notice, i18n } = art
const qualityDefault = quality.find(item => item.default) || quality[0]
controls.update({
name: 'quality',
position: 'right',
index: 10,
style: { marginRight: '10px' },
html: qualityDefault?.html || '',
selector: quality,
async onSelect(item, _element, event) {
const active = captureSelection(event)
// This callback is installed with the quality array above.
await art.switchQuality((item as QualityItem).url)
if (!isClosing(art) && active()) {
const message = `${i18n.get('Switch Video')}: ${item.html}`
if (!isClosing(art) && active())
notice.show = message
}
return item.html
},
})
},
})
}
+9 -2
View File
@@ -6,12 +6,17 @@
display: flex;
align-items: center;
justify-content: space-between;
height: var(--art-control-height);
flex-wrap: wrap;
flex-shrink: 0;
height: auto;
min-height: var(--art-control-height);
.art-controls-left,
.art-controls-right {
display: flex;
height: 100%;
flex-wrap: wrap;
max-width: 100%;
min-height: var(--art-control-height);
}
.art-controls-center {
@@ -23,11 +28,13 @@
.art-controls-right {
justify-content: flex-end;
margin-left: auto;
}
.art-control {
.art-center();
flex-shrink: 0;
max-width: 100%;
cursor: pointer;
white-space: nowrap;
opacity: var(--art-control-opacity);
+7 -2
View File
@@ -20,7 +20,7 @@
padding: 10px;
line-height: 1;
left: var(--art-padding);
bottom: calc(var(--art-control-height) + var(--art-bottom-gap) + 10px);
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + 10px);
background-color: var(--art-widget-background);
.art-auto-playback-close {
@@ -55,7 +55,12 @@
.art-controls,
.art-progress {
transform: translateY(calc(var(--art-control-height) + var(--art-bottom-gap) + var(--art-progress-height) / 4));
transform: translateY(
calc(
var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + var(--art-progress-height) /
4
)
);
}
.art-progress-indicator {
+1 -1
View File
@@ -10,7 +10,7 @@
border-radius: var(--art-border-radius);
max-height: var(--art-settings-max-height);
right: var(--art-padding);
bottom: var(--art-control-height);
bottom: var(--art-controls-height, var(--art-control-height));
transition: all var(--art-transition-duration) ease;
background-color: var(--art-widget-background);
+1 -1
View File
@@ -31,7 +31,7 @@
&.art-control-show {
.art-subtitle {
bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-subtitle-bottom));
}
}
}
-120
View File
@@ -1,120 +0,0 @@
import validator from 'option-validator'
import { ComponentOption } from '../scheme'
import { addClass, append, createElement, hasClass, remove, removeClass, setStyles, tooltip } from './dom'
import { errorHandle } from './error'
export default class Component {
constructor(art) {
this.id = 0
this.art = art
this.cache = new Map()
this.add = this.add.bind(this)
this.remove = this.remove.bind(this)
this.update = this.update.bind(this)
}
get show() {
return hasClass(this.art.template.$player, `art-${this.name}-show`)
}
set show(value) {
const { $player } = this.art.template
const className = `art-${this.name}-show`
if (value) {
addClass($player, className)
}
else {
removeClass($player, className)
}
this.art.emit(this.name, value)
}
toggle() {
this.show = !this.show
}
add(getOption) {
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
option.html = option.html || ''
validator(option, ComponentOption)
if (!this.$parent || !this.name || option.disable)
return
const name = option.name || `${this.name}${this.id}`
errorHandle(!this.cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
this.id += 1
const $ref = createElement('div')
addClass($ref, `art-${this.name}`)
addClass($ref, `art-${this.name}-${name}`)
const childs = Array.from(this.$parent.children)
$ref.dataset.index = option.index || this.id
const nextChild = childs.find(item => Number(item.dataset.index) >= Number($ref.dataset.index))
if (nextChild) {
nextChild.insertAdjacentElement('beforebegin', $ref)
}
else {
append(this.$parent, $ref)
}
if (option.html) {
append($ref, option.html)
}
if (option.style) {
setStyles($ref, option.style)
}
if (option.tooltip) {
tooltip($ref, option.tooltip)
}
const events = []
if (option.click) {
const destroyEvent = this.art.events.proxy($ref, 'click', (event) => {
event.preventDefault()
option.click.call(this.art, this, event)
})
events.push(destroyEvent)
}
if (option.selector && ['left', 'right'].includes(option.position)) {
this.selector(option, $ref, events)
}
this[name] = $ref
this.cache.set(name, { $ref, events, option })
if (option.mounted) {
option.mounted.call(this.art, $ref)
}
return $ref
}
remove(name) {
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
const item = this.cache.get(name)
if (item.option.beforeUnmount) {
item.option.beforeUnmount.call(this.art, item.$ref)
}
for (const event of item.events) {
this.art.events.remove(event)
}
this.cache.delete(name)
delete this[name]
remove(item.$ref)
}
update(option) {
if (this.cache.has(option.name)) {
const item = this.cache.get(option.name)
option = Object.assign(item.option, option)
this.remove(option.name)
}
return this.add(option)
}
}
+158
View File
@@ -0,0 +1,158 @@
import type { ComponentHost, Entry, EntryInput, EntryOption, EventCleanup } from '../component/types'
import validator from 'option-validator'
import { renderEntry } from '../component/dom'
import { ownEntry, releaseEntry } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { ComponentOption } from '../scheme'
import { addClass, hasClass, removeClass } from './dom'
import { errorHandle } from './error'
const removing = new WeakSet<object>()
export default class Component<Host extends ComponentHost = ComponentHost> {
declare id: number
declare art: Host
declare cache: Map<string, Entry<Host>>
declare name?: string
declare $parent?: HTMLElement
selector?(option: EntryOption<Host>, element: HTMLDivElement, events: EventCleanup[]): void
constructor(art: Host) {
this.id = 0
this.art = art
this.cache = new Map()
this.add = this.add.bind(this)
this.remove = this.remove.bind(this)
this.update = this.update.bind(this)
}
get show(): boolean {
return hasClass(this.art.template.$player, `art-${this.name}-show`)
}
set show(value: boolean) {
const { $player } = this.art.template
const className = `art-${this.name}-show`
if (value)
addClass($player, className)
else
removeClass($player, className)
this.art.emit(this.name!, value)
}
toggle(): void {
this.show = !this.show
}
add(getOption: EntryInput<Host>): HTMLDivElement | undefined {
if (isClosing(this.art))
return
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
if (isClosing(this.art))
return
option.html = option.html || ''
validator(option, ComponentOption)
if (!this.$parent || !this.name || option.disable)
return
const name = option.name || `${this.name}${this.id}`
const cache = this.cache
errorHandle(!cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
this.id += 1
const $ref = document.createElement('div')
const scope = ownEntry(this.art, $ref)
const events: EventCleanup[] = []
const previous = Object.getOwnPropertyDescriptor(this, name)
let aliased = false
scope.add(() => {
for (const event of events)
this.art.events.remove(event)
})
try {
if (scope.closed)
return
renderEntry($ref, this.$parent, this.name, name, this.id, option)
if (scope.closed)
return
if (option.click) {
const cleanup = this.art.events.proxy($ref, 'click', (event) => {
if (scope.closed)
return
event.preventDefault()
option.click!.call(this.art, this, event)
})
events.push(cleanup)
}
if (option.selector && ['left', 'right'].includes(option.position!))
this.selector!(option, $ref, events)
if (scope.closed)
return
assignAlias(this, name, $ref)
aliased = true
cache.set(name, { $ref, events, option })
if (option.mounted)
option.mounted.call(this.art, $ref)
return $ref
}
catch (error) {
try {
releaseEntry($ref)
}
catch (cleanupError) {
console.warn('ArtPlayer component cleanup failed:', cleanupError)
}
if (cache.get(name)?.$ref === $ref)
cache.delete(name)
if (aliased && (this as unknown as Record<string, unknown>)[name] === $ref) {
if (previous)
Object.defineProperty(this, name, previous)
else
delete (this as unknown as Record<string, unknown>)[name]
}
$ref.remove()
throw error
}
}
remove(name: string): void {
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
const item = this.cache.get(name)!
if (removing.has(item))
return
removing.add(item)
try {
if (item.option.beforeUnmount)
item.option.beforeUnmount.call(this.art, item.$ref)
try {
releaseEntry(item.$ref)
}
finally {
if (this.cache.get(name) === item) {
this.cache.delete(name)
delete (this as unknown as Record<string, unknown>)[name]
}
item.$ref.remove()
}
}
finally {
removing.delete(item)
}
}
update(option: EntryOption<Host>): ReturnType<this['add']> {
if (this.cache.has(option.name!)) {
const item = this.cache.get(option.name!)!
option = Object.assign(item.option, option)
this.remove(option.name!)
}
// add is polymorphic: Control intentionally returns undefined.
return this.add(option) as ReturnType<this['add']>
}
}
function assignAlias(owner: object, name: string, value: HTMLElement): void {
const aliases = owner as Record<string, unknown>
if (name === '__proto__')
Object.defineProperty(aliases, name, { value, enumerable: true, configurable: true, writable: true })
else
aliases[name] = value
}