refactor(core): CORE-14 split typed settings and scope tree lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 06:49:04 +08:00
1 parent 697817eceb
commit 64d1939daa
51 files changed
+28144 -1519

No files matched your search

+85
View File
@@ -4,6 +4,91 @@ 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.
## Settings (CORE-14)
setting/index.ts remains the specialized manager: panel cache keys are option arrays,
add/update return the original item, remove returns undefined and missing find returns
null. A narrow constructor type view preserves the actual Component prototype chain
without imposing its incompatible string-keyed cache or registry return types.
It delegates tree work to setting/model.ts and measurement to setting/layout.ts.
The tree model preserves object/array identity and immutable hidden getter descriptors,
while rebinding a removed/moved node to its current parent. Automatic names skip explicit
names anywhere in the tree. Structural validation runs before installing bindings;
traverse retains preorder and callback-driven child insertion. Model ownership uses
an opaque owner state and root scope, with no direct player/manager reference. Closing
the owner clears its root and scope references so bindings do not retain the old owner.
It rejects an item that still belongs to another active tree before modifying either
tree. Removing the subtree or closing its owner permits original-object reuse, with
the same immutable getters and event array. Simultaneous sharing was already broken
in the published player (the second instance had no row); it now reports an explicit
ownership error instead of allowing the new player to steal the first player's row.
The layout module computes dimensions independently from DOM reads. Width is bounded
by player width and resolved bottom padding; height fits above actual control rows,
using the existing scrolling panel. Control coordinates account for CSS scaling.
The existing right-edge CSS fallback and mobile/rotation positioning branches remain.
render.ts now owns DOM/descriptor rendering, selection.ts owns callback generations,
and resources.ts owns item listeners, deferred mounts and recursive panel disposal.
The four builtin setting factories are TS and release their Emitter subscriptions.
events.ts owns root subscriptions and size/style observers. Control height changes
can animate the panel's bottom offset; transitionend recalculates its final height.
The MutationObserver fallback also consumes the existing resize event for container
changes. All these callbacks stop when the instance closes.
Failed additions unlink the attempted entry without releasing a duplicate's existing
listeners. Successfully formatted new entries also release partial render resources.
registration.ts prevents an obsolete failed add from removing a reentrant successful
add/update of the same object; removal and update supersede the pending registration.
Removal finishes row cleanup and root rendering even if event removal throws, then
reports collected errors. Nonextensible metadata and unwritable generated names are
checked before binding earlier entries. These checks do not roll back arbitrary
user getter/setter or Proxy side effects.
panels.ts gives each cached panel a root-owned scope. Rows are child scopes and the
back header is owned by its panel while retaining the parent's public event array.
Disposing the panel removes its cache entry, listeners, pending mounts and DOM.
New-panel rendering checkpoints item descriptors and the original position of raw
content nodes, without calling user getters. A failure restores those inputs, the
previous active panel and owned layout properties. Creation stops if a header hook
removes the parent or destroys the instance.
update.ts suspends the old subtree instead of disposing it before assignment. The
same public event array temporarily holds only replacement registrations; suspended
scopes retain their old registrations privately. activity.ts prevents suspended
callbacks and asynchronous writes. Success disposes the old resources; failure
resumes the original registrations and restores descriptors, real DOM nodes, range
array identity/live input values, switch state, tree bindings and owned layout.
The current input getter order is retained, but assignment stops after a reentrant
operation supersedes it. Pending operations are tracked across their descendants;
removing an ancestor cancels its child's update before releasing the subtree.
Renderers retain their captured operation/scope so a late getter failure cannot
dispose a newer row. Cleanup failures after a committed replacement are reported;
the successful new row is not rolled back to already-disposed resources. Arbitrary
user accessor/Proxy side effects remain outside the managed-state rollback boundary.
Cached-panel navigation now restores the previous panel/layout on failure. A newer
navigation, removal or destruction supersedes that restoration, even if the failing
outer call resumes afterward. It does not rebuild cached rows or their listeners.
Long labels and tooltips shrink with ellipsis while retaining their full DOM content.
Icons and native controls keep their width, including in a 320px player. Tests cover
trusted keyboard input/change ordering, pointer switch callbacks, touch selection,
and actual builtin autoOrientation transforms with a local video. Mobile UA/viewport
and touch emulation are not certification on physical mobile devices.
Public Setting return declaration discrepancies remain tracked by BASE-TYPE-07 for
the compatible public facade at CORE-21. The source manager has precise item/null/void
returns and does not change runtime behavior to match incorrect legacy declarations.
Tests: setting-model.test.js, setting-layout.test.js, setting-resources.test.js,
types/setting-model.ts, types/setting-manager.ts and
browser/setting.spec.js, browser/setting-update.spec.js and browser/setting-ownership.spec.js.
browser/setting-interaction.spec.js covers real input, long labels and mobile rotation.
Run the shared test scripts and the full installed UMD/legacy browser matrices after
changing these boundaries. The per-package and final release tasks own the complete
ecosystem/SDK matrix, remote CI and physical-device release review.
## Components and controls (CORE-13)
The public registries remain Component-based. utils/component.ts owns add/update/remove,
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var 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,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+1221 -454
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
import type ResourceScope from '../lifecycle/scope'
const pauses = new WeakMap<ResourceScope, number>()
export function settingScopeActive(scope: ResourceScope): boolean {
return !scope.closed && !pauses.get(scope)
}
export function pauseSettingScope(scope: ResourceScope): () => void {
pauses.set(scope, (pauses.get(scope) || 0) + 1)
let paused = true
return () => {
if (!paused)
return
paused = false
const count = (pauses.get(scope) || 1) - 1
if (count)
pauses.set(scope, count)
else
pauses.delete(scope)
}
}
@@ -1,11 +1,14 @@
export default function aspectRatio(art) {
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function aspectRatio(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, ASPECT_RATIO },
} = art
function getI18n(value) {
function getI18n(value: string) {
return value === 'default' ? i18n.get('Default') : value
}
@@ -29,12 +32,12 @@ export default function aspectRatio(art) {
}
}),
onSelect(item) {
art.aspectRatio = item.value
art.aspectRatio = item.value as string
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('aspectRatio', () => update())
subscribeSetting(art, item, 'aspectRatio', () => update())
},
}
}
@@ -0,0 +1,40 @@
import type { SettingItem } from './types'
type CaptureState = () => () => void
const states = new WeakMap<SettingItem, CaptureState>()
export function rememberSettingState(item: SettingItem, capture?: CaptureState): void {
if (capture)
states.set(item, capture)
else
states.delete(item)
}
// Capture descriptors without invoking user getters while preparing a render.
export function captureSettingItem(item: SettingItem): () => void {
const descriptors = new Map(Reflect.ownKeys(item).map(key => [key, Object.getOwnPropertyDescriptor(item, key)!]))
const nodes: { node: Node, parent: Node | null, next: Node | null }[] = []
const state = states.get(item)
const restoreState = state?.()
for (const key of ['html', 'icon', 'tooltip']) {
const value: unknown = descriptors.get(key)?.value
if (value instanceof Node)
nodes.push({ node: value, parent: value.parentNode, next: value.nextSibling })
}
return () => {
for (const key of Reflect.ownKeys(item)) {
if (!descriptors.has(key) && Object.getOwnPropertyDescriptor(item, key)?.configurable)
Reflect.deleteProperty(item, key)
}
for (const [key, descriptor] of descriptors)
Object.defineProperty(item, key, descriptor)
restoreState?.()
rememberSettingState(item, state)
for (const { node, parent, next } of nodes) {
if (parent)
parent.insertBefore(node, next?.parentNode === parent ? next : null)
else
node.parentNode?.removeChild(node)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { SettingEvents, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function installSettingEvents(setting: SettingManager): void {
const { art } = setting
const scope = getScope(art).child()
const on = <Name extends keyof SettingEvents>(name: Name, callback: (...args: SettingEvents[Name]) => unknown) => {
if (scope.closed || isClosing(art))
return
const guarded = (...args: SettingEvents[Name]) => {
if (!scope.closed && !isClosing(art))
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
on('blur', () => {
if (setting.show) {
setting.show = false
setting.render()
}
})
on('focus', (event) => {
const isControl = includeFromEvent(event, art.controls.setting)
const isSetting = includeFromEvent(event, setting.$parent)
if (setting.show && !isControl && !isSetting) {
setting.show = false
setting.render()
}
})
on('resize', () => setting.resize())
const resize = () => {
if (!scope.closed && !isClosing(art))
setting.resize()
}
const transitioned = art.proxy(setting.$parent, 'transitionend', (event) => {
if (event.target === setting.$parent && (event as TransitionEvent).propertyName === 'bottom')
resize()
})
scope.add(() => {
art.events.remove(transitioned)
})
const { $player, $controls } = art.template
if (!scope.closed && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(resize)
observer.observe($player)
observer.observe($controls)
scope.add(() => {
observer.disconnect()
})
}
if (!scope.closed && typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(resize)
if (typeof ResizeObserver === 'undefined')
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
// Controls can publish their measured height after our ResizeObserver callback.
observer.observe($player, { attributes: true, attributeFilter: ['class', 'style'] })
scope.add(() => {
observer.disconnect()
})
}
}
@@ -1,13 +1,15 @@
import type { SettingHost, SettingItem } from './types'
import { capitalize } from '../utils'
import { subscribeSetting } from './resources'
export default function flip(art) {
export default function flip(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, FLIP },
} = art
function getI18n(value) {
function getI18n(value: string) {
return i18n.get(capitalize(value))
}
@@ -31,12 +33,12 @@ export default function flip(art) {
}
}),
onSelect(item) {
art.flip = item.value
art.flip = item.value as string
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('flip', () => update())
subscribeSetting(art, item, 'flip', () => update())
},
}
}
-558
View File
@@ -1,558 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import {
addClass,
append,
createElement,
def,
errorHandle,
getRect,
has,
includeFromEvent,
inverseClass,
isMobile,
remove,
replaceElement,
setStyle,
} from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import flip from './flip'
import playbackRate from './playbackRate'
import subtitleOffset from './subtitleOffset'
export default class Setting extends Component {
constructor(art) {
super(art)
const {
option,
controls,
template: { $setting },
} = art
this.name = 'setting'
this.$parent = $setting
this.id = 0
this.active = null
this.cache = new Map()
this.option = [...this.builtin, ...option.settings]
if (option.setting) {
this.format()
this.render()
art.on('blur', () => {
if (this.show) {
this.show = false
this.render()
}
})
art.on('focus', (event) => {
const isControl = includeFromEvent(event, controls.setting)
const isSetting = includeFromEvent(event, this.$parent)
if (this.show && !isControl && !isSetting) {
this.show = false
this.render()
}
})
art.on('resize', () => this.resize())
}
}
get builtin() {
const result = []
const { option } = this.art
if (option.playbackRate) {
result.push(playbackRate(this.art))
}
if (option.aspectRatio) {
result.push(aspectRatio(this.art))
}
if (option.flip) {
result.push(flip(this.art))
}
if (option.subtitleOffset) {
result.push(subtitleOffset(this.art))
}
return result
}
traverse(callback, option = this.option) {
for (let index = 0; index < option.length; index++) {
const item = option[index]
callback(item)
if (item.selector?.length) {
this.traverse(callback, item.selector)
}
}
}
check(target) {
if (!target) {
return
}
target.$parent.tooltip = target.html
this.traverse((item) => {
item.default = item === target
if (item.default && item.$item) {
inverseClass(item.$item, 'art-current')
}
}, target.$option)
this.render(target.$parents)
}
format(option = this.option, parent, parents, names = []) {
for (let index = 0; index < option.length; index++) {
const item = option[index]
if (item?.name) {
errorHandle(!names.includes(item.name), `The [${item.name}] already exists in [setting]`)
names.push(item.name)
}
else {
item.name = `setting-${this.id++}`
}
if (!item.$formatted) {
def(item, '$parent', {
get: () => parent,
})
def(item, '$parents', {
get: () => parents,
})
def(item, '$option', {
get: () => option,
})
const $events = []
def(item, '$events', {
get: () => $events,
})
def(item, '$formatted', {
get: () => true,
})
}
this.format(item.selector || [], item, option, names)
}
this.option = option
}
find(name = '') {
let result = null
this.traverse((item) => {
if (item.name === name) {
result = item
}
})
return result
}
resize() {
const {
controls,
constructor: { SETTING_WIDTH, SETTING_ITEM_HEIGHT },
template: { $player, $setting },
} = this.art
if (controls.setting && this.show) {
const settingWidth = this.active[0]?.$parent?.width || SETTING_WIDTH
const { left: controlLeft, width: controlWidth } = getRect(controls.setting)
const { left: playerLeft, width: playerWidth } = getRect($player)
const settingLeft = controlLeft - playerLeft + controlWidth / 2 - settingWidth / 2
const settingHeight
= this.active === this.option
? this.active.length * SETTING_ITEM_HEIGHT
: (this.active.length + 1) * SETTING_ITEM_HEIGHT
setStyle($setting, 'height', `${settingHeight}px`)
setStyle($setting, 'width', `${settingWidth}px`)
if (this.art.isRotate || isMobile)
return
if (settingLeft + settingWidth > playerWidth) {
setStyle($setting, 'left', null)
setStyle($setting, 'right', null)
}
else {
setStyle($setting, 'left', `${settingLeft}px`)
setStyle($setting, 'right', 'auto')
}
}
}
inactivate(item) {
for (let index = 0; index < item.$events.length; index++) {
this.art.events.remove(item.$events[index])
}
item.$events.length = 0
}
remove(name) {
const item = this.find(name)
errorHandle(item, `Can't find [${name}] in the [setting]`)
const index = item.$option.indexOf(item)
item.$option.splice(index, 1)
this.inactivate(item)
if (item.$item)
remove(item.$item)
this.render()
}
update(target) {
const item = this.find(target.name)
if (item) {
this.inactivate(item)
Object.assign(item, target)
this.format()
this.createItem(item, true)
this.render()
return item
}
else {
return this.add(target)
}
}
add(item, option = this.option) {
option.push(item)
this.format()
this.createItem(item)
this.render()
return item
}
createHeader(item) {
if (!this.cache.has(item.$option))
return
const $panel = this.cache.get(item.$option)
const {
proxy,
icons: { arrowLeft },
constructor: { SETTING_ITEM_HEIGHT },
} = this.art
const $item = createElement('div')
setStyle($item, 'height', `${SETTING_ITEM_HEIGHT}px`)
addClass($item, 'art-setting-item')
addClass($item, 'art-setting-item-back')
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $icon = createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
append($left, $icon)
append($left, item.$parent.html)
const event = proxy($item, 'click', () => this.render(item.$parents))
item.$parent.$events.push(event)
append($panel, $item)
}
createItem(item, isUpdate = false) {
if (!this.cache.has(item.$option))
return
const $panel = this.cache.get(item.$option)
const oldItem = item.$item
let type = 'selector'
if (has(item, 'switch')) {
type = 'switch'
}
if (has(item, 'range')) {
type = 'range'
}
if (has(item, 'onClick')) {
type = 'button'
}
const { icons, proxy, constructor } = this.art
const $item = createElement('div')
addClass($item, 'art-setting-item')
setStyle($item, 'height', `${constructor.SETTING_ITEM_HEIGHT}px`)
$item.dataset.name = item.name || ''
$item.dataset.value = item.value || ''
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $right = append($item, '<div class="art-setting-item-right"></div>')
const $icon = createElement('div')
addClass($icon, 'art-setting-item-left-icon')
switch (type) {
case 'button':
case 'switch':
case 'range':
append($icon, item.icon || icons.config)
break
case 'selector':
if (item.selector?.length) {
append($icon, item.icon || icons.config)
}
else {
append($icon, icons.check)
}
break
default:
break
}
append($left, $icon)
def(item, '$icon', {
configurable: true,
get: () => $icon,
})
def(item, 'icon', {
configurable: true,
get() {
return $icon.innerHTML
},
set(value) {
$icon.innerHTML = ''
append($icon, value)
},
})
const $html = createElement('div')
addClass($html, 'art-setting-item-left-text')
append($html, item.html || '')
append($left, $html)
def(item, '$html', {
configurable: true,
get: () => $html,
})
def(item, 'html', {
configurable: true,
get() {
return $html.innerHTML
},
set(value) {
$html.innerHTML = ''
append($html, value)
},
})
const $tooltip = createElement('div')
addClass($tooltip, 'art-setting-item-right-tooltip')
append($tooltip, item.tooltip || '')
append($right, $tooltip)
def(item, '$tooltip', {
configurable: true,
get: () => $tooltip,
})
def(item, 'tooltip', {
configurable: true,
get() {
return $tooltip.innerHTML
},
set(value) {
$tooltip.innerHTML = ''
append($tooltip, value)
},
})
switch (type) {
case 'switch': {
const $switch = createElement('div')
addClass($switch, 'art-setting-item-right-icon')
const $switchOn = append($switch, icons.switchOn)
const $switchOff = append($switch, icons.switchOff)
setStyle(item.switch ? $switchOff : $switchOn, 'display', 'none')
append($right, $switch)
def(item, '$switch', {
configurable: true,
get: () => $switch,
})
let $switchValue = item.switch
def(item, 'switch', {
configurable: true,
get: () => $switchValue,
set(value) {
$switchValue = value
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
}
else {
setStyle($switchOff, 'display', null)
setStyle($switchOn, 'display', 'none')
}
},
})
break
}
case 'range':
{
const $state = createElement('div')
addClass($state, 'art-setting-item-right-icon')
const $range = append($state, '<input type="range">')
$range.value = item.range[0]
$range.min = item.range[1]
$range.max = item.range[2]
$range.step = item.range[3]
addClass($range, 'art-setting-range')
append($right, $state)
def(item, '$range', {
configurable: true,
get: () => $range,
})
let $rangeValue = [...item.range]
def(item, 'range', {
configurable: true,
get: () => $rangeValue,
set(value) {
$rangeValue = [...value]
$range.value = value[0]
$range.min = value[1]
$range.max = value[2]
$range.step = value[3]
},
})
}
break
case 'selector':
if (item.selector?.length) {
const $state = createElement('div')
addClass($state, 'art-setting-item-right-icon')
append($state, icons.arrowRight)
append($right, $state)
}
break
default:
break
}
switch (type) {
case 'switch': {
if (item.onSwitch) {
const event = proxy($item, 'click', async (event) => {
item.switch = await item.onSwitch.call(this.art, item, $item, event)
})
item.$events.push(event)
}
break
}
case 'range': {
if (item.$range) {
if (item.onRange) {
const event = proxy(item.$range, 'change', async (event) => {
item.range[0] = item.$range.valueAsNumber
item.tooltip = await item.onRange.call(this.art, item, $item, event)
})
item.$events.push(event)
}
if (item.onChange) {
const event = proxy(item.$range, 'input', async (event) => {
item.range[0] = item.$range.valueAsNumber
item.tooltip = await item.onChange.call(this.art, item, $item, event)
})
item.$events.push(event)
}
}
break
}
case 'selector':
{
const event = proxy($item, 'click', async (event) => {
if (item.selector?.length) {
this.render(item.selector)
}
else {
this.check(item)
if (item.$parent.onSelect) {
item.$parent.tooltip = await item.$parent.onSelect.call(this.art, item, $item, event)
}
}
})
item.$events.push(event)
if (item.default) {
addClass($item, 'art-current')
}
}
break
case 'button':
if (item.onClick) {
const event = proxy($item, 'click', async (event) => {
item.tooltip = await item.onClick.call(this.art, item, $item, event)
})
item.$events.push(event)
}
break
default:
break
}
def(item, '$item', {
configurable: true,
get: () => $item,
})
if (isUpdate) {
replaceElement($item, oldItem)
}
else {
append($panel, $item)
}
if (item.mounted) {
timeout(getScope(this.art), () => item.mounted.call(this.art, item.$item, item), 0)
}
}
render(option = this.option) {
this.active = option
if (this.cache.has(option)) {
const $panel = this.cache.get(option)
inverseClass($panel, 'art-current')
}
else {
const $panel = createElement('div')
this.cache.set(option, $panel)
addClass($panel, 'art-setting-panel')
append(this.$parent, $panel)
inverseClass($panel, 'art-current')
if (option[0]?.$parent) {
this.createHeader(option[0])
}
for (let index = 0; index < option.length; index++) {
this.createItem(option[index])
}
}
this.resize()
}
}
+185
View File
@@ -0,0 +1,185 @@
import type { SettingHost, SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { errorHandle } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import { installSettingEvents } from './events'
import flip from './flip'
import { resizeSetting } from './layout'
import { findItem, formatTree, registerTreeOwner, releaseTreeOwner, traverseTree } from './model'
import playbackRate from './playbackRate'
import { beginSettingAdd, cancelSettingAdd } from './registration'
import { createSettingHeader, createSettingItem, renderSetting } from './render'
import { releaseSettingTree } from './resources'
import { checkSetting } from './selection'
import subtitleOffset from './subtitleOffset'
import { cancelSettingUpdate, updateSetting } from './update'
// Keep the exact runtime inheritance; Setting overrides the registry operations and cache.
const SettingBase: new (art: SettingHost) => Pick<Component<SettingHost>, 'id' | 'art' | 'show' | 'toggle'> = Component
export default class Setting extends SettingBase implements SettingManager {
declare name: string
declare $parent: HTMLElement
declare cache: Map<SettingItem[], HTMLDivElement>
declare active: SettingItem[] | null
declare option: SettingItem[]
constructor(art: SettingHost) {
super(art)
const {
option,
template: { $setting },
} = art
this.name = 'setting'
this.$parent = $setting
this.id = 0
this.active = null
this.cache = new Map()
this.option = [...this.builtin, ...option.settings]
const scope = getScope(art)
registerTreeOwner(this, scope)
scope.add(() => {
releaseTreeOwner(this)
})
if (option.setting) {
this.format()
this.render()
installSettingEvents(this)
}
}
get builtin(): SettingItem[] {
const result: SettingItem[] = []
const { option } = this.art
if (option.playbackRate) {
result.push(playbackRate(this.art))
}
if (option.aspectRatio) {
result.push(aspectRatio(this.art))
}
if (option.flip) {
result.push(flip(this.art))
}
if (option.subtitleOffset) {
result.push(subtitleOffset(this.art))
}
return result
}
traverse(callback: (item: SettingItem) => void, option = this.option): void {
traverseTree(option, callback)
}
check(target?: SettingItem | null): void {
checkSetting(this, target)
}
format(option = this.option, parent?: SettingItem, parents?: SettingItem[], names: string[] = []): void {
this.option = formatTree(this, option, parent, parents, names)
}
find(name = ''): SettingItem | null {
return findItem(this.option, name)
}
resize(): void {
resizeSetting(this)
}
inactivate(item: SettingItem): void {
releaseSettingTree(this, item)
}
remove(name: string): void {
const item = this.find(name)!
errorHandle(item, `Can't find [${name}] in the [setting]`)
this.traverse((item) => {
cancelSettingAdd(item)
cancelSettingUpdate(item)
}, [item])
const index = item.$option!.indexOf(item)
item.$option!.splice(index, 1)
const element = item.$item
const failures: unknown[] = []
for (const cleanup of [() => this.inactivate(item), () => element?.remove(), () => this.render()]) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
if (failures.length)
throw new ResourceCleanupError(failures)
}
update(target: SettingItem): SettingItem {
return updateSetting(this, target)
}
add<Item extends SettingItem>(item: Item, option: SettingItem[] = this.option): Item {
if (isClosing(this.art))
return item
let registered = false
this.traverse((existing) => {
registered ||= existing === item
})
const index = option.length
const registration = registered ? undefined : beginSettingAdd(item)
let formatted = false
try {
option.push(item)
this.format()
formatted = true
this.createItem(item)
this.render()
}
catch (error) {
if (isClosing(this.art) || (registration && !registration.current()))
throw error
if (option[index] === item)
option.splice(index, 1)
if (!registered && formatted) {
try {
this.inactivate(item)
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
item.$item?.remove()
}
throw error
}
finally {
registration?.finish()
}
return item
}
createHeader(item: SettingItem): void {
createSettingHeader(this, item)
}
createItem(item: SettingItem, isUpdate = false): void {
createSettingItem(this, item, isUpdate)
}
render(option = this.option): void {
renderSetting(this, option)
}
}
+71
View File
@@ -0,0 +1,71 @@
import { isMobile } from '../utils/compatibility'
interface LayoutItem {
readonly $parent?: { width?: number }
}
export interface SettingLayout {
active: LayoutItem[] | null
option: LayoutItem[]
show: boolean
art: {
isRotate: boolean
controls: { setting?: HTMLElement }
constructor: { SETTING_WIDTH: number, SETTING_ITEM_HEIGHT: number }
template: { $player: HTMLElement, $setting: HTMLElement, $bottom: HTMLElement }
}
}
export interface LayoutInput {
containerWidth: number
containerHeight: number
requestedWidth: number
rows: number
rowHeight: number
controlCenter: number
bottom: number
padding: number
}
export function calculateSettingLayout(input: LayoutInput): { width: number, height: number, left: number } {
const { containerWidth, containerHeight, requestedWidth, rows, rowHeight, controlCenter, bottom, padding } = input
const inset = Math.min(Math.max(0, padding), Math.max(0, containerWidth / 2))
const width = Math.min(Math.max(0, requestedWidth), Math.max(0, containerWidth - 2 * inset))
const height = Math.min(Math.max(0, rows * rowHeight), Math.max(0, containerHeight - bottom - inset))
const left = Math.max(inset, Math.min(controlCenter - width / 2, containerWidth - inset - width))
return { width, height, left }
}
export function resizeSetting(setting: SettingLayout): void {
const { art, active } = setting
const { controls, constructor: { SETTING_WIDTH, SETTING_ITEM_HEIGHT }, template: { $player, $setting, $bottom } } = art
if (!controls.setting || !setting.show || !active)
return
const player = $player.getBoundingClientRect()
const control = controls.setting.getBoundingClientRect()
const scale = player.width / $player.offsetWidth || 1
const controlCenter = (control.left - player.left + control.width / 2) / scale - $player.clientLeft
const layout = calculateSettingLayout({
containerWidth: $player.clientWidth,
containerHeight: $player.clientHeight,
requestedWidth: active[0]?.$parent?.width || SETTING_WIDTH,
rows: active.length + (active === setting.option ? 0 : 1),
rowHeight: SETTING_ITEM_HEIGHT,
controlCenter,
bottom: Number.parseFloat(getComputedStyle($setting).bottom) || 0,
padding: Number.parseFloat(getComputedStyle($bottom).paddingLeft) || 0,
})
$setting.style.height = `${layout.height}px`
$setting.style.width = `${layout.width}px`
if (art.isRotate || isMobile)
return
if (controlCenter + layout.width / 2 > $player.clientWidth) {
// Preserve the existing CSS right-edge anchor when centering would overflow.
$setting.style.left = ''
$setting.style.right = ''
}
else {
$setting.style.left = `${layout.left}px`
$setting.style.right = 'auto'
}
}
+183
View File
@@ -0,0 +1,183 @@
import { errorHandle } from '../utils/error'
export interface TreeItem {
name?: string
selector?: TreeItem[]
readonly $parent?: TreeItem
readonly $parents?: TreeItem[]
readonly $option?: TreeItem[]
readonly $events?: (() => void)[]
readonly $formatted?: boolean
}
export interface TreeBinding {
parent?: TreeItem
parents?: TreeItem[]
option: TreeItem[]
events: (() => void)[]
owner: TreeOwnerState
}
interface TreeOwnerState {
active: boolean
root?: TreeItem[]
scope?: { readonly closed: boolean }
}
interface Placement {
item: TreeItem
parent?: TreeItem
parents?: TreeItem[]
option: TreeItem[]
name: string
generated: boolean
}
const bindings = new WeakMap<TreeItem, TreeBinding>()
const owners = new WeakMap<object, TreeOwnerState>()
const keys = ['$parent', '$parents', '$option', '$events', '$formatted'] as const
export function hasTreeBinding(item: TreeItem): boolean {
return bindings.has(item)
}
export function registerTreeOwner(owner: object, scope: { readonly closed: boolean }): void {
owners.set(owner, { active: true, scope })
}
export function releaseTreeOwner(owner: object): void {
const state = owners.get(owner)
if (state) {
state.active = false
state.root = undefined
state.scope = undefined
}
}
function activeBinding(item: TreeItem): boolean {
const visited = new Set<TreeItem>()
let current: TreeItem | undefined = item
while (current && !visited.has(current)) {
visited.add(current)
const binding: TreeBinding | undefined = bindings.get(current)
if (!binding || !binding.owner.active || binding.owner.scope?.closed || !binding.option.includes(current))
return false
if (!binding.parent)
return binding.owner.root === binding.option
if (bindings.get(binding.parent)?.owner !== binding.owner)
return false
current = binding.parent
}
return false
}
function canAssignName(item: TreeItem): boolean {
let target: object | null = item
while (target) {
const descriptor = Object.getOwnPropertyDescriptor(target, 'name')
if (descriptor) {
if ('set' in descriptor)
return typeof descriptor.set === 'function'
return Boolean(descriptor.writable && (target === item || Object.isExtensible(item)))
}
target = Object.getPrototypeOf(target)
}
return Object.isExtensible(item)
}
export function treeBinding(item: TreeItem): TreeBinding {
const binding = bindings.get(item)
if (!binding)
throw new Error('Setting item has not been formatted')
return binding
}
export function traverseTree(option: TreeItem[], callback: (item: TreeItem) => void): void {
for (let index = 0; index < option.length; index++) {
const item = option[index]!
callback(item)
if (item.selector?.length)
traverseTree(item.selector, callback)
}
}
export function findItem(option: TreeItem[], name = ''): TreeItem | null {
let result: TreeItem | null = null
traverseTree(option, (item) => {
if (item.name === name)
result = item
})
return result
}
// Validate the complete structure before installing any names or bindings.
export function formatTree<Item extends TreeItem>(owner: { id: number }, option: Item[], parent?: TreeItem, parents?: TreeItem[], names: string[] = []): Item[] {
const ownerState = owners.get(owner) || { active: true }
const placements: Placement[] = []
const seen = new Set<TreeItem>()
const used = new Set(names)
const collect = (list: TreeItem[], parent?: TreeItem, parents?: TreeItem[]) => {
for (let index = 0; index < list.length; index++) {
const item = list[index]!
if (!item || typeof item !== 'object')
throw new TypeError('Setting item must be an object')
if (seen.has(item))
throw new Error('Setting items must appear only once in a tree')
seen.add(item)
const previous = bindings.get(item)
if (previous && previous.owner !== ownerState && activeBinding(item))
errorHandle(false, `Setting item [${item.name || ''}] already belongs to another active player`)
if (item.name) {
errorHandle(!used.has(item.name), `The [${item.name}] already exists in [setting]`)
used.add(item.name)
}
if (!bindings.has(item)) {
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(item, key)
if (descriptor ? !descriptor.configurable : !Object.isExtensible(item))
throw new TypeError(`Cannot format setting item property [${key}]`)
}
}
if (!item.name && !canAssignName(item))
throw new TypeError('Cannot assign an automatic setting item name')
placements.push({ item, parent, parents, option: list, name: item.name || '', generated: !item.name })
if (item.selector?.length)
collect(item.selector, item, list)
}
}
collect(option, parent, parents)
let nextId = owner.id
owners.set(owner, ownerState)
ownerState.root = option
for (const placement of placements) {
if (!placement.name) {
do {
placement.name = `setting-${nextId++}`
} while (used.has(placement.name))
used.add(placement.name)
}
}
for (const placement of placements) {
const { item, name, parent, parents, option } = placement
const previous = bindings.get(item)
if (!previous) {
Object.defineProperties(item, {
$parent: { get: () => treeBinding(item).parent },
$parents: { get: () => treeBinding(item).parents },
$option: { get: () => treeBinding(item).option },
$events: { get: () => treeBinding(item).events },
$formatted: { get: () => true },
})
}
bindings.set(item, { parent, parents, option, events: previous?.events || [], owner: ownerState })
if (placement.generated)
item.name = name
}
// Preserve the caller-provided accumulator and monotonic autogenerated IDs.
for (const placement of placements) {
if (!placement.generated)
names.push(placement.name)
}
owner.id = nextId
return option
}
+31
View File
@@ -0,0 +1,31 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingItem, SettingManager } from './types'
import { getScope } from '../lifecycle/instance'
const scopes = new WeakMap<HTMLDivElement, ResourceScope>()
export function ownSettingPanel(setting: SettingManager, option: SettingItem[], panel: HTMLDivElement): ResourceScope {
const scope = getScope(setting.art).child()
scopes.set(panel, scope)
scope.add(() => {
if (setting.cache.get(option) === panel)
setting.cache.delete(option)
panel.remove()
})
return scope
}
export function settingPanelScope(panel: HTMLDivElement): ResourceScope {
const scope = scopes.get(panel)
if (!scope)
throw new Error('Setting panel has not been registered')
return scope
}
export function releaseSettingPanel(panel: HTMLDivElement): void {
const scope = scopes.get(panel)
if (scope)
scope.dispose()
else
panel.remove()
}
@@ -1,11 +1,14 @@
export default function playbackRate(art) {
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function playbackRate(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, PLAYBACK_RATE },
} = art
function getI18n(value) {
function getI18n(value: number) {
return value === 1.0 ? i18n.get('Normal') : value.toFixed(1)
}
@@ -29,12 +32,12 @@ export default function playbackRate(art) {
}
}),
onSelect(item) {
art.playbackRate = item.value
art.playbackRate = item.value as number
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('video:ratechange', () => update())
subscribeSetting(art, item, 'video:ratechange', () => update())
},
}
}
@@ -0,0 +1,20 @@
import type { SettingItem } from './types'
const registrations = new WeakMap<SettingItem, object>()
export function cancelSettingAdd(item: SettingItem): void {
registrations.delete(item)
}
export function beginSettingAdd(item: SettingItem): { current: () => boolean, finish: () => void } {
const token = {}
registrations.set(item, token)
const current = () => registrations.get(item) === token
return {
current,
finish() {
if (current())
registrations.delete(item)
},
}
}
+378
View File
@@ -0,0 +1,378 @@
import type { SettingKind } from './selection'
import type { SettingItem, SettingManager, SettingRange } from './types'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { addClass, append, def, has, inverseClass, setStyle } from '../utils'
import { settingScopeActive } from './activity'
import { captureSettingItem, rememberSettingState } from './checkpoint'
import { ownSettingPanel, releaseSettingPanel, settingPanelScope } from './panels'
import { ownSettingItem, proxySetting, settingItemOwner } from './resources'
import { bindSettingActions } from './selection'
import { captureSettingUpdate } from './update'
const navigations = new WeakMap<SettingManager, number>()
function bindContent(item: SettingItem, key: 'icon' | 'html' | 'tooltip', element: HTMLDivElement): void {
def(item, `$${key}`, { configurable: true, get: () => element })
def(item, key, {
configurable: true,
get: () => element.innerHTML,
set(value: unknown) {
element.innerHTML = ''
append(element, value)
},
})
}
export function createSettingHeader(setting: SettingManager, item: SettingItem): void {
if (!setting.cache.has(item.$option!))
return
const $panel = setting.cache.get(item.$option!)!
const {
icons: { arrowLeft },
constructor: { SETTING_ITEM_HEIGHT },
} = setting.art
const $item = document.createElement('div')
setStyle($item, 'height', `${SETTING_ITEM_HEIGHT}px`)
addClass($item, 'art-setting-item')
addClass($item, 'art-setting-item-back')
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
append($left, $icon)
append($left, item.$parent!.html)
proxySetting(setting.art, item.$parent!, $item, 'click', () => setting.render(item.$parents), settingPanelScope($panel))
append($panel, $item)
}
export function createSettingItem(setting: SettingManager, item: SettingItem, isUpdate = false): void {
const currentUpdate = captureSettingUpdate(item)
if (!setting.cache.has(item.$option!))
return
const $panel = setting.cache.get(item.$option!)!
const oldItem = item.$item
let type: SettingKind = 'selector'
if (has(item, 'switch')) {
type = 'switch'
}
if (has(item, 'range')) {
type = 'range'
}
if (has(item, 'onClick')) {
type = 'button'
}
const { icons, constructor } = setting.art
if (!currentUpdate())
return
const scope = ownSettingItem(setting.art, item, settingPanelScope($panel))
const current = () => settingScopeActive(scope) && !isClosing(setting.art) && currentUpdate()
if (!current())
return
const $item = document.createElement('div')
try {
rememberSettingState(item)
addClass($item, 'art-setting-item')
setStyle($item, 'height', `${constructor.SETTING_ITEM_HEIGHT}px`)
$item.dataset.name = item.name || ''
$item.dataset.value = String(item.value || '')
if (!current())
return
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $right = append($item, '<div class="art-setting-item-right"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
switch (type) {
case 'button':
case 'switch':
case 'range': {
const icon = item.icon || icons.config
if (!current())
return
append($icon, icon)
break
}
case 'selector':
if (item.selector?.length) {
const icon = item.icon || icons.config
if (!current())
return
append($icon, icon)
}
else {
append($icon, icons.check)
}
break
default:
break
}
append($left, $icon)
if (!current())
return
bindContent(item, 'icon', $icon)
const $html = document.createElement('div')
addClass($html, 'art-setting-item-left-text')
const html = item.html || ''
if (!current())
return
append($html, html)
append($left, $html)
bindContent(item, 'html', $html)
const $tooltip = document.createElement('div')
addClass($tooltip, 'art-setting-item-right-tooltip')
const tooltip = item.tooltip || ''
if (!current())
return
append($tooltip, tooltip)
append($right, $tooltip)
bindContent(item, 'tooltip', $tooltip)
switch (type) {
case 'switch': {
const $switch = document.createElement('div')
addClass($switch, 'art-setting-item-right-icon')
const $switchOn = append($switch, icons.switchOn)
const $switchOff = append($switch, icons.switchOff)
const initialSwitch = item.switch
if (!current())
return
setStyle(initialSwitch ? $switchOff : $switchOn, 'display', 'none')
append($right, $switch)
def(item, '$switch', {
configurable: true,
get: () => $switch,
})
let $switchValue = item.switch
if (!current())
return
rememberSettingState(item, () => {
const value = $switchValue
return () => {
$switchValue = value
}
})
def(item, 'switch', {
configurable: true,
get: () => $switchValue,
set(value) {
$switchValue = value
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
}
else {
setStyle($switchOff, 'display', null)
setStyle($switchOn, 'display', 'none')
}
},
})
break
}
case 'range':
{
const $state = document.createElement('div')
addClass($state, 'art-setting-item-right-icon')
const $range = document.createElement('input')
$range.type = 'range'
append($state, $range)
for (const [index, key] of ['value', 'min', 'max', 'step'].entries()) {
const value = item.range![index]
if (!current())
return
Reflect.set($range, key, String(value))
}
addClass($range, 'art-setting-range')
append($right, $state)
def(item, '$range', {
configurable: true,
get: () => $range,
})
let $rangeValue = [...item.range!]
if (!current())
return
rememberSettingState(item, () => {
const value = $rangeValue
const values = [...value]
const input = { min: $range.min, max: $range.max, step: $range.step, value: $range.value }
return () => {
value.splice(0, value.length, ...values)
$rangeValue = value
Object.assign($range, input)
}
})
def(item, 'range', {
configurable: true,
get: () => $rangeValue,
set(value: SettingRange) {
$rangeValue = [...value]
$range.value = String(value[0])
$range.min = String(value[1])
$range.max = String(value[2])
$range.step = String(value[3])
},
})
}
break
case 'selector':
if (item.selector?.length) {
const $state = document.createElement('div')
addClass($state, 'art-setting-item-right-icon')
append($state, icons.arrowRight)
append($right, $state)
}
break
default:
break
}
if (!current())
return
bindSettingActions(setting, item, $item, type)
if (!current())
return
def(item, '$item', {
configurable: true,
get: () => $item,
})
if (isUpdate) {
if (oldItem?.parentNode)
oldItem.replaceWith($item)
else
append($panel, $item)
}
else {
append($panel, $item)
}
if (item.mounted && current()) {
timeout(scope, () => {
if (isClosing(setting.art))
return
try {
const result = item.mounted?.call(setting.art, $item, item)
void Promise.resolve(result).catch(error => console.warn('ArtPlayer setting mounted failed:', error))
}
catch (error) {
console.warn('ArtPlayer setting mounted failed:', error)
}
}, 0)
}
}
catch (error) {
try {
scope.dispose()
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
$item.remove()
throw error
}
}
export function renderSetting(setting: SettingManager, option = setting.option): void {
if (isClosing(setting.art))
return
const cached = setting.cache.get(option)
if (cached && !settingScopeActive(settingPanelScope(cached)))
return
const navigation = (navigations.get(setting) || 0) + 1
navigations.set(setting, navigation)
const currentNavigation = () => navigations.get(setting) === navigation && !isClosing(setting.art)
const previous = setting.active
const previousLayout = ['height', 'width', 'left', 'right'].map(key => [key, setting.$parent.style.getPropertyValue(key), setting.$parent.style.getPropertyPriority(key)] as const)
const restoreNavigation = () => {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
for (const [key, value, priority] of previousLayout)
setting.$parent.style.setProperty(key, value, priority)
}
setting.active = option
if (cached) {
try {
inverseClass(cached, 'art-current')
setting.resize()
}
catch (error) {
if (currentNavigation() && setting.cache.get(option) === cached && !settingPanelScope(cached).closed)
restoreNavigation()
throw error
}
}
else {
const $panel = document.createElement('div')
setting.cache.set(option, $panel)
const scope = ownSettingPanel(setting, option, $panel)
const current = () => !scope.closed && !isClosing(setting.art) && setting.cache.get(option) === $panel
const checkpoints: { item: SettingItem, restore: () => void }[] = []
try {
if (!current())
return
addClass($panel, 'art-setting-panel')
append(setting.$parent, $panel)
inverseClass($panel, 'art-current')
if (option[0]?.$parent)
setting.createHeader(option[0])
for (let index = 0; index < option.length && current(); index++) {
const item = option[index]!
checkpoints.push({ item, restore: captureSettingItem(item) })
setting.createItem(item)
}
if (!current())
return
setting.resize()
}
catch (error) {
const restore = current()
const restoreActive = setting.active === option && currentNavigation()
try {
releaseSettingPanel($panel)
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
if (restore && !isClosing(setting.art)) {
for (const checkpoint of checkpoints.reverse()) {
if (settingItemOwner(checkpoint.item) === scope) {
try {
checkpoint.restore()
}
catch (restoreError) {
console.warn('ArtPlayer setting restore failed:', restoreError)
}
}
}
if (restoreActive)
restoreNavigation()
}
throw error
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingEvents, SettingHost, SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { pauseSettingScope, settingScopeActive } from './activity'
import { hasTreeBinding, treeBinding } from './model'
import { releaseSettingPanel } from './panels'
const scopes = new WeakMap<SettingItem, ResourceScope>()
const owners = new WeakMap<SettingItem, ResourceScope>()
const eventLists = new WeakMap<ResourceScope, (() => void)[]>()
function releaseEvents(art: SettingHost, events: (() => void)[]): void {
const pending = events.splice(0)
const failures: unknown[] = []
for (const cleanup of pending) {
try {
art.events.remove(cleanup)
}
catch (error) {
failures.push(error)
}
}
if (failures.length)
throw new ResourceCleanupError(failures)
}
export function ownSettingItem(art: SettingHost, item: SettingItem, owner = getScope(art)): ResourceScope {
const previous = scopes.get(item)
if (!previous || settingScopeActive(previous) || previous.closed)
releaseSettingItem(art, item)
const scope = owner.child()
scopes.set(item, scope)
owners.set(item, owner)
eventLists.set(scope, treeBinding(item).events)
scope.add(() => {
releaseEvents(art, eventLists.get(scope)!)
})
return scope
}
export function settingItemOwner(item: SettingItem): ResourceScope | undefined {
return owners.get(item)
}
export function settingScope(item: SettingItem): ResourceScope {
const scope = scopes.get(item)
if (!scope)
throw new Error('Setting item has not been rendered')
return scope
}
export function releaseSettingItem(art: SettingHost, item: SettingItem): void {
const scope = scopes.get(item)
if (scope && !scope.closed)
scope.dispose()
else if (hasTreeBinding(item))
releaseEvents(art, treeBinding(item).events)
}
export interface SettingSuspension {
resume: () => void
dispose: () => void
}
export function suspendSettingItem(art: SettingHost, item: SettingItem): SettingSuspension | undefined {
const previous = scopes.get(item)
if (!previous || previous.closed)
return
if (!settingScopeActive(previous))
throw new Error('Setting item update is already in progress')
const owner = owners.get(item)!
const events = treeBinding(item).events
const saved = events.splice(0)
eventLists.set(previous, saved)
const unpause = pauseSettingScope(previous)
let pending = true
return {
resume() {
if (!pending)
return
pending = false
const failures: unknown[] = []
const current = scopes.get(item)
for (const cleanup of [() => {
if (current && current !== previous)
current.dispose()
}, () => releaseEvents(art, events)]) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
if (!previous.closed && !isClosing(art)) {
events.push(...saved.splice(0))
eventLists.set(previous, events)
scopes.set(item, previous)
owners.set(item, owner)
}
unpause()
if (failures.length)
throw new ResourceCleanupError(failures)
},
dispose() {
if (!pending)
return
pending = false
try {
previous.dispose()
}
finally {
unpause()
}
},
}
}
export function proxySetting(art: SettingHost, item: SettingItem, target: EventTarget, name: string, callback: (event: Event) => unknown, owner?: ResourceScope): void {
const scope = owner || settingScope(item)
if (!settingScopeActive(scope) || isClosing(art))
return
const event = art.proxy(target, name, (event) => {
if (settingScopeActive(scope) && !isClosing(art)) {
try {
return callback(event)
}
catch (error) {
console.warn('ArtPlayer setting callback failed:', error)
}
}
})
if (!settingScopeActive(scope) || isClosing(art)) {
art.events.remove(event)
}
else {
treeBinding(item).events.push(event)
if (owner && owner !== scopes.get(item)) {
owner.add(() => {
const events = treeBinding(item).events
const index = events.indexOf(event)
if (index >= 0) {
events.splice(index, 1)
art.events.remove(event)
}
})
}
}
}
export function subscribeSetting<Name extends keyof SettingEvents>(art: SettingHost, item: SettingItem, name: Name, callback: (...args: SettingEvents[Name]) => unknown): void {
const scope = settingScope(item)
if (!settingScopeActive(scope) || isClosing(art))
return
const guarded = (...args: SettingEvents[Name]) => {
if (settingScopeActive(scope) && !isClosing(art))
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
export function releaseSettingTree(setting: SettingManager, item: SettingItem): void {
const failures: unknown[] = []
const visited = new Set<SettingItem>()
const visit = (item: SettingItem) => {
if (visited.has(item))
return
visited.add(item)
for (const child of item.selector || [])
visit(child)
if (item.selector) {
const panel = setting.cache.get(item.selector)
setting.cache.delete(item.selector)
try {
if (panel)
releaseSettingPanel(panel)
}
catch (error) {
failures.push(error)
}
}
try {
releaseSettingItem(setting.art, item)
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
visit(item)
if (failures.length)
throw new ResourceCleanupError(failures)
}
@@ -0,0 +1,83 @@
import type { SettingCallback, SettingItem, SettingManager } from './types'
import { isClosing } from '../lifecycle/instance'
import { addClass, inverseClass } from '../utils/dom'
import { settingScopeActive } from './activity'
import { proxySetting, settingScope } from './resources'
export type SettingKind = 'button' | 'selector' | 'switch' | 'range'
const generations = new WeakMap<SettingItem, number>()
export function checkSetting(setting: SettingManager, target?: SettingItem | null): void {
if (!target?.$parent)
return
target.$parent.tooltip = target.html
setting.traverse((item) => {
item.default = item === target
if (item.default && item.$item)
inverseClass(item.$item, 'art-current')
}, target.$option)
setting.render(target.$parents)
}
async function act(setting: SettingManager, item: SettingItem, element: HTMLDivElement, event: Event, callback: SettingCallback, target: SettingItem, property: 'switch' | 'tooltip', before?: () => void): Promise<void> {
try {
const scope = settingScope(item)
const targetScope = settingScope(target)
const generation = (generations.get(target) || 0) + 1
generations.set(target, generation)
const active = () => settingScopeActive(scope) && settingScopeActive(targetScope) && !isClosing(setting.art) && generations.get(target) === generation
before?.()
if (!active())
return
const value = await callback.call(setting.art, item, element, event)
if (active())
Reflect.set(target, property, value)
}
catch (error) {
console.warn('ArtPlayer setting callback failed:', error)
}
}
export function bindSettingActions(setting: SettingManager, item: SettingItem, element: HTMLDivElement, kind: SettingKind): void {
const { art } = setting
const scope = settingScope(item)
const proxy = (target: EventTarget, name: string, callback: (event: Event) => unknown) => proxySetting(art, item, target, name, callback, scope)
switch (kind) {
case 'switch':
if (item.onSwitch)
proxy(element, 'click', event => act(setting, item, element, event, item.onSwitch!, item, 'switch'))
break
case 'range': {
const range = item.$range
if (range) {
for (const [name, callback] of [['change', 'onRange'], ['input', 'onChange']] as const) {
if (item[callback]) {
proxy(range, name, event => act(setting, item, element, event, item[callback]!, item, 'tooltip', () => {
item.range![0] = range.valueAsNumber
}))
}
}
}
break
}
case 'selector':
proxy(element, 'click', (event) => {
if (item.selector?.length) {
setting.render(item.selector)
}
else if (item.$parent?.onSelect) {
return act(setting, item, element, event, item.$parent.onSelect, item.$parent, 'tooltip', () => setting.check(item))
}
else {
setting.check(item)
}
})
if (item.default)
addClass(element, 'art-current')
break
case 'button':
if (item.onClick)
proxy(element, 'click', event => act(setting, item, element, event, item.onClick!, item, 'tooltip'))
break
}
}
@@ -1,22 +0,0 @@
export default function subtitleOffset(art) {
const { i18n, icons, constructor } = art
return {
width: constructor.SETTING_ITEM_WIDTH,
name: 'subtitle-offset',
html: i18n.get('Subtitle Offset'),
icon: icons.subtitle,
tooltip: '0s',
range: [0, -10, 10, 0.1],
onChange(item) {
art.subtitleOffset = item.range[0]
return `${item.range[0]}s`
},
mounted: (_, item) => {
art.on('subtitleOffset', (value) => {
item.$range.value = value
item.tooltip = `${value}s`
})
},
}
}
@@ -0,0 +1,25 @@
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function subtitleOffset(art: SettingHost): SettingItem {
const { i18n, icons, constructor } = art
return {
width: constructor.SETTING_ITEM_WIDTH,
name: 'subtitle-offset',
html: i18n.get('Subtitle Offset'),
icon: icons.subtitle,
tooltip: '0s',
range: [0, -10, 10, 0.1],
onChange(item) {
art.subtitleOffset = item.range![0]!
return `${item.range![0]}s`
},
mounted: (_, item) => {
subscribeSetting(art, item, 'subtitleOffset', (value) => {
item.$range!.value = String(value)
item.tooltip = `${value}s`
})
},
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type { TreeItem } from './model'
export type SettingContent = string | HTMLElement | number
export type SettingRange = [value?: number, min?: number, max?: number, step?: number]
export type SettingCallback = (this: SettingHost, item: SettingItem, element: HTMLDivElement, event: Event) => unknown
export interface SettingItem extends TreeItem {
html?: SettingContent
icon?: SettingContent
tooltip?: SettingContent
width?: number
value?: string | number
default?: boolean
switch?: boolean
range?: SettingRange
selector?: SettingItem[]
mounted?: (this: SettingHost, element: HTMLDivElement, item: SettingItem) => unknown
onClick?: SettingCallback
onSwitch?: SettingCallback
onRange?: SettingCallback
onChange?: SettingCallback
onSelect?: SettingCallback
readonly $parent?: SettingItem
readonly $parents?: SettingItem[]
readonly $option?: SettingItem[]
readonly $item?: HTMLDivElement
readonly $icon?: HTMLDivElement
readonly $html?: HTMLDivElement
readonly $tooltip?: HTMLDivElement
readonly $switch?: HTMLDivElement
readonly $range?: HTMLInputElement
}
export interface SettingEvents {
'blur': []
'focus': [event: Event]
'resize': []
'video:ratechange': []
'aspectRatio': [value: string]
'flip': [value: string]
'subtitleOffset': [value: number]
}
export interface SettingHost extends ComponentHost, SubscriptionHost<SettingEvents> {
template: { $player: HTMLElement, $setting: HTMLElement, $bottom: HTMLElement, $controls: HTMLElement }
controls: { setting?: HTMLElement }
option: { setting: boolean, settings: SettingItem[], playbackRate: boolean, aspectRatio: boolean, flip: boolean, subtitleOffset: boolean }
constructor: { SETTING_WIDTH: number, SETTING_ITEM_HEIGHT: number, SETTING_ITEM_WIDTH: number, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
icons: Record<'arrowLeft' | 'arrowRight' | 'config' | 'check' | 'switchOn' | 'switchOff' | 'playbackRate' | 'aspectRatio' | 'flip' | 'subtitle', HTMLElement>
proxy: ComponentHost['events']['proxy']
i18n: { get: (key: string) => string }
setting: SettingManager
isRotate: boolean
playbackRate: number
aspectRatio: string
flip: string
subtitleOffset: number
}
export interface SettingManager {
id: number
art: SettingHost
name: string
$parent: HTMLElement
cache: Map<SettingItem[], HTMLDivElement>
active: SettingItem[] | null
option: SettingItem[]
show: boolean
traverse: (callback: (item: SettingItem) => void, option?: SettingItem[]) => void
check: (target?: SettingItem | null) => void
render: (option?: SettingItem[]) => void
resize: () => void
createItem: (item: SettingItem, isUpdate?: boolean) => void
createHeader: (item: SettingItem) => void
find: (name?: string) => SettingItem | null
format: () => void
add: <Item extends SettingItem>(item: Item, option?: SettingItem[]) => Item
}
+210
View File
@@ -0,0 +1,210 @@
import type { SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import captureTemplate from '../lifecycle/template-rollback'
import { inverseClass } from '../utils/dom'
import { pauseSettingScope } from './activity'
import { captureSettingItem } from './checkpoint'
import { releaseSettingPanel, settingPanelScope } from './panels'
import { cancelSettingAdd } from './registration'
import { suspendSettingItem } from './resources'
const pending = new WeakMap<SettingItem, () => void>()
function finish(cleanups: (() => void)[]): void {
const errors: unknown[] = []
for (const cleanup of cleanups) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
errors.push(...error.errors)
else
errors.push(error)
}
}
if (errors.length)
throw new ResourceCleanupError(errors)
}
function suspendTree(setting: SettingManager, root: SettingItem): { items: Set<SettingItem>, resume: () => void, dispose: () => void } {
const items = new Set<SettingItem>()
const panels = new Set<HTMLDivElement>()
const collect = (item: SettingItem) => {
if (items.has(item))
return
items.add(item)
if (item.selector) {
const panel = setting.cache.get(item.selector)
if (panel)
panels.add(panel)
item.selector.forEach(collect)
}
}
collect(root)
const suspended: NonNullable<ReturnType<typeof suspendSettingItem>>[] = []
const unpause: (() => void)[] = []
try {
for (const item of items) {
const suspension = suspendSettingItem(setting.art, item)
if (suspension)
suspended.push(suspension)
}
for (const panel of panels)
unpause.push(pauseSettingScope(settingPanelScope(panel)))
}
catch (error) {
try {
finish([...suspended.reverse().map(item => item.resume), ...unpause])
}
catch (cleanupError) {
console.warn('ArtPlayer setting restore failed:', cleanupError)
}
throw error
}
let active = true
return {
items,
resume() {
if (!active)
return
active = false
finish([...suspended.reverse().map(item => item.resume), ...unpause])
},
dispose() {
if (!active)
return
active = false
finish([...suspended.reverse().map(item => item.dispose), ...Array.from(panels).reverse().map(panel => () => releaseSettingPanel(panel)), ...unpause])
},
}
}
// A newer operation restores the pending one before applying its own changes.
export function cancelSettingUpdate(item: SettingItem): void {
pending.get(item)?.()
}
export function captureSettingUpdate(item: SettingItem): () => boolean {
const operation = pending.get(item)
return () => !operation || pending.get(item) === operation
}
function assignSetting(item: SettingItem, target: SettingItem, current: () => boolean): void {
for (const key of Reflect.ownKeys(target)) {
if (!current())
return
const descriptor = Object.getOwnPropertyDescriptor(target, key)
if (!descriptor?.enumerable || !current())
continue
const value: unknown = Reflect.get(target, key)
if (!current())
return
if (!Reflect.set(item, key, value))
throw new TypeError(`Cannot assign setting item property [${String(key)}]`)
}
}
export function updateSetting(setting: SettingManager, target: SettingItem): SettingItem {
const item = setting.find(target.name)
if (isClosing(setting.art))
return item || target
if (!item)
return setting.add(target)
cancelSettingAdd(item)
cancelSettingUpdate(item)
const restoreItem = captureSettingItem(item)
const element = item.$item
const parent = element?.parentNode
const next = element?.nextSibling
const restoreTemplate = element ? captureTemplate(element) : undefined
const previous = setting.active
const layout = ['height', 'width', 'left', 'right'].map(key => [key, setting.$parent.style.getPropertyValue(key), setting.$parent.style.getPropertyPriority(key)] as const)
const tree = suspendTree(setting, item)
let active = true
let rendered = false
const cancel = () => {
if (!active)
return
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
if (isClosing(setting.art)) {
tree.dispose()
return
}
const replacement = item.$item
try {
finish([
tree.resume,
() => {
if (replacement && replacement !== element)
replacement.remove()
},
restoreItem,
() => restoreTemplate?.(),
() => {
if (parent && element)
parent.insertBefore(element, next?.parentNode === parent ? next : null)
},
() => setting.format(),
() => {
if (rendered) {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
for (const [key, value, priority] of layout)
setting.$parent.style.setProperty(key, value, priority)
}
},
])
}
catch (error) {
console.warn('ArtPlayer setting restore failed:', error)
}
}
const current = () => active && !isClosing(setting.art) && pending.get(item) === cancel
for (const affected of tree.items)
pending.set(affected, cancel)
const release = getScope(setting.art).add(() => {
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
tree.dispose()
})
try {
assignSetting(item, target, current)
if (!current())
return item
setting.format()
if (!current())
return item
setting.createItem(item, true)
if (!current())
return item
rendered = true
setting.render()
if (!current())
return item
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
tree.dispose()
return item
}
catch (error) {
if (current())
cancel()
throw error
}
finally {
release()
}
}
+19 -1
View File
@@ -26,6 +26,7 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 5px;
padding: 0 5px;
cursor: pointer;
overflow: hidden;
@@ -50,28 +51,45 @@
.art-setting-item-left {
.art-center();
flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
gap: 5px;
.art-setting-item-left-icon {
.art-center();
flex-shrink: 0;
height: var(--art-settings-icon-size);
width: var(--art-settings-icon-size);
}
.art-setting-item-left-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.art-setting-item-right {
.art-center();
flex-shrink: 0;
min-width: 0;
max-width: 55%;
gap: 5px;
font-size: 12px;
.art-setting-item-right-tooltip {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.5);
}
.art-setting-item-right-icon {
.art-center();
flex-shrink: 0;
min-width: 32px;
height: 24px;
}