refactor(core): CORE-16 type display modes and own fullscreen lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 09:42:30 +08:00
1 parent 1b83081e8f
commit 88aef830df
81 files changed
+33897 -1503

No files matched your search

+175
View File
@@ -4,6 +4,181 @@ 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.
## Display modes (CORE-16, in progress)
player/autoSizeMix.ts, autoHeightMix.ts, aspectRatioMix.ts and flipMix.ts retain the
own-property facades, detached method binding, synchronous returns and normal events.
display/sizing.ts contains geometry only; sizing-types.ts describes the minimal hosts.
Zero/unready/non-finite media or container dimensions defer auto sizing without layout
writes or invalid height events; the next valid call measures and applies normally.
Aspect ratios retain split/Number coercion, datasets, notices and repeated events.
Malformed or unusable ratios do not partially overwrite existing video geometry.
Flip keeps custom strings and falsy normalization. Closing instances ignore these writes.
Run `node --test test/display-sizing.test.js` and
`yarn test:browser test/browser/display-sizing.spec.js` for geometry invariants, old/new
property contracts, hidden-container recovery, notices and destruction. Composition with
screen rotation is tested below; final installed-artifact acceptance remains a CORE-16 gate.
plugins/autoOrientation.ts composes the mobile-only builtin, retaining its name/state
getter and mismatch rule. It rejects unready geometry and scopes the two ArtPlayer event
subscriptions. orientation-types.ts models the minimal host and optional platform lock.
The builtin registry currently bridges its generic JS host at this one assembly boundary;
the complete constructor host remains for CORE-21, without widening the implementation.
display/orientation-web.ts owns delayed rotation and its four inline style properties.
Each new fullscreen session cancels the previous timer. Repeated entry reapplies dimensions
without replacing the first snapshot. On ordinary exit fullscreenWeb has already restored
the full entry style, so rotation only clears its flags; destroy restores rotation properties
before fullscreenWeb performs its own final restoration. Resize may update control CSS
variables independently. The scope cancels timers and removes subscriptions on destruction.
display/orientation-native.ts owns lock requests, error notices and fullscreen rotation
classes. It calls lock synchronously, marks success only while current, and cancels on exit
or destroy. A weak per-platform-object token prevents cancelled or older instances from
unlocking a newer ArtPlayer request. Late cancelled success is released; obsolete failures
cannot overwrite current notices. Unlock is best effort, consistent with the prior API;
arbitrary application calls outside ArtPlayer cannot be tracked as owned locks.
Run `node --test test/display-orientation.test.js` and
`yarn test:browser test/browser/display-orientation.spec.js` for cancellation, retry, shared
screen ownership, repeated entry, styles, timers, real rotated playback and ratio/flip
composition. The browser fixture uses a mobile UA with desktop engines and controlled
orientation.lock; it does not establish physical screen rotation or safe-area device support.
The [Screen Orientation specification](https://www.w3.org/TR/screen-orientation/) permits
platform preconditions for lock and defines superseding requests; capability and device
acceptance must stay distinct from controlled request-state tests.
player/fullscreenWebMix.ts is the boolean property facade. display/web-fullscreen.ts
owns a single entry-session snapshot, reentry generations and destroy restoration;
display/placement.ts captures the original parent and next sibling. Repeated entry
preserves the first snapshot while keeping the existing true events. Exit restores
the captured position even if FULLSCREEN_WEB_IN_BODY changes while active. Destruction
restores the player before template.destroy, so removeHtml still owns the same tree.
Failed exit retains the snapshot for retry; reentrant event/DOM callbacks supersede
the earlier setter. Normal fullscreenWeb/resize ordering remains synchronous.
Destroy is terminal: if restoring the original position throws before reattachment,
detach the displaced player so template cleanup cannot leave an orphan in body. The
original error still propagates; if detachment also fails, the lifecycle collector
retains both failures. A node already returned to its owner is left for normal
removeHtml handling. This exceptional fallback can leave retained HTML detached.
Restore the exact style attribute before the exit event; downstream resize handlers
may update their own CSS variables. display-web.spec.js checks that distinction along
with original placement, repeated entry, failure, reentry and destruction. The native
fullscreen/PiP/mini/sizing/rotation paths have migrated source, while complete
acceptance remain CORE-16; this section does not certify physical mobile devices.
player/fullscreenMix.ts still installs the fullscreen descriptor once after metadata.
display/fullscreen-adapter.ts reuses the unchanged screenfull vendor method mapping;
display/native-fullscreen.ts owns instance state, native events, cancellation and notices.
The getter and exit operation concern this player's container/video, not any document
fullscreen element. Normal fullscreen events precede class changes and resize. Reentrant
setters invalidate stale work while same-state reentry still updates the interface.
display/fullscreen-request.ts invokes the native API on the caller stack, preserving
transient user activation. Native Promise results remain authoritative for their errors;
void prefixed APIs settle from change/error events. Void exit waits until neither the
instance player nor video owns fullscreen, even
when the request target was the player. Another element becoming fullscreen completes
the old exit without giving this instance ownership of that new element. Cancelled calls
settle promptly and lose their instance listeners. Descriptor callers can observe rejection identity;
ordinary assignments report a notice without creating an unhandled rejection.
Unabortable void entry has no Promise to observe after destruction. Only cancellation
of that path creates display/fullscreen-abandoned.ts's document-lifetime change guard.
It keeps weak element keys and native exit operations, never player instances/scopes;
unrelated targets are ignored and a new entry clears the old cancellation. This shared
guard intentionally outlives instances, unlike their ordinary change/error listeners.
Post-destroy native exit is best effort: browser denial cannot be undone synchronously.
Run `node --test test/display-native.test.js` for controlled async/reentry semantics.
For native gesture, recovery and subtitle checks:
```sh
yarn test:browser test/browser/display-native.spec.js test/browser/display-web.spec.js test/browser/subtitle-lifecycle.spec.js
```
Void cancellation in desktop browsers uses controlled native methods; it does not certify
old browser versions or iOS video-only fullscreen. Remaining failure boundaries and
final installed distribution acceptance remain in CORE-16.
display/video-fullscreen.ts owns the video-only WebKit fallback. It retains the
synchronous property setter and native thrown error, and listens to video begin/end,
presentation-mode changes and the existing document event bridge. The state helper in
display/video-fullscreen-state.ts distinguishes fullscreen from PiP when a presentation
mode exists, otherwise reads webkitDisplayingFullscreen or the video event fallback.
Repeated signals emit one transition; an exit failure retains actual state for retry.
Destroy releases all instance listeners and exits active video presentation. A pending
unabortable entry leaves only a static listener on that video with a weak cancellation
mark; this callback references no ArtPlayer/scope and exits late entry. New entry removes
the cancellation. Synchronous exit failure during destroy propagates through the normal
lifecycle cleanup collector; late cancellation cleanup is best effort. No document-wide
listener is installed for this video-only path.
Use `node --test test/display-video-fullscreen.test.js` and
`yarn test:browser test/browser/display-video-fullscreen.spec.js` for controlled fallback
checks, plus native/fullscreenWeb/subtitle tests for composition. The desktop fixture
forces the fallback and emulates native video events; it does not prove physical iOS
presentation or gesture behavior. Apple documents the distinct video events in
[Controlling Media with JavaScript](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/ControllingMediaWithJavaScript/ControllingMediaWithJavaScript.html).
player/pipMix.ts chooses callable native, WebKit or unsupported capabilities and installs
the original property descriptor. display/native-pip.ts keeps the element/null getter,
void setter, native entry/leave events and failure notices. It never exits another
video's PiP; revisions suppress obsolete notices, cancellation rejects late entry, and
destroy disposes listeners before exiting owned presentation. Request invocation remains
synchronous for transient activation. Async rejection is handled internally because the
historical void setter cannot expose a Promise; native synchronous errors still throw.
An old queued leave event is ignored while this video still owns the current PiP session;
actual ownership loss continues to emit the existing false notification.
display/webkit-pip.ts keeps its boolean getter and initial inline setup. It observes both
presentation-mode and PiP events, deduplicates native signals, and retains repeated
synchronous setter notifications. Capability is checked at entry so later media readiness
can allow retry. Cancelled pending entry leaves only a static video-held callback and weak
mark, with no art/scope closure. Inline cleanup never exits a different fullscreen mode.
Destruction's synchronous WebKit failure follows the normal lifecycle error collector;
late cleanup is best effort. Platform UI remains the browser's responsibility.
The internal PipProperty reflects the branch-dependent getter. Historical public boolean
declarations remain unchanged pending BASE-TYPE-10 / CORE-21; this is an explicit legacy
declaration discrepancy, not a reason to convert native JS consumers to boolean.
Run `node --test test/display-pip.test.js` and
`yarn test:browser test/browser/display-pip.spec.js test/browser/display-pip-webkit.spec.js`.
The actual native test records capabilities and, when supported, a native window, media
progress, exit and fullscreenWeb/native-fullscreen transitions. WebKit event injection
is separate from physical Safari/iOS validation. See the
[PiP specification](https://w3c.github.io/picture-in-picture/) and
[Apple's presentation-mode API](https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls).
player/miniMix.ts keeps the boolean mini descriptor. display/mini.ts owns entry/exit
generations and video placement; mini-view.ts owns popup creation, controls, playback
subscriptions and node removal. mini-drag.ts owns document mouse subscriptions and drag
state; mini-layout.ts calculates finite saved/default coordinates and viewport bounds.
The first popup keeps video-first DOM order and its default display; reuse moves the video
last and uses flex as before. Repeated true/false events and storage keys left/top remain.
Capture the original video parent and next sibling once per mini session. Exit restores
that location, with the snapshot retained on failure for retry. Detach the snapshot before
restoring DOM so a reentrant entry can capture its own placement. Serialize entry during
view creation to avoid duplicate popups from custom icon getters. Failed creation disposes
its partial view; hide/destroy during construction never leaves visible stale UI.
Created popups and playback/drag listeners belong to the instance scope; hide keeps a
reusable hidden popup but cancels drag, and destroy removes owned nodes and restores the
video. A caller-provided template.$mini stays caller-owned and regains its prior display
on destroy. Drag uses client coordinates for fixed positioning, clamps its final position,
and writes numeric left/top. Normal defaults retain a 50px inset; non-finite/offscreen
storage resets, and CSS max-width/max-height keep the popup inside a smaller viewport.
Run `node --test test/display-mini.test.js` and
`yarn test:browser test/browser/display-mini.spec.js test/browser/display-pip.spec.js`.
Browser checks include video placement, playback buttons, actual drag, failure/reentry,
caller ownership, narrow screens and transitions from mini to web fullscreen/native PiP.
Final installed display-mode acceptance is still pending with CORE-16.
## Subtitles (CORE-15)
src/subtitle/index.ts keeps the Component prototype, bound update, public methods and
+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 n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,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
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+1200 -460
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
import type { NativeFullscreenAdapter } from './types'
const abandoned = new WeakMap<Document, WeakMap<Element, () => unknown>>()
export function hasAbandonedFullscreen(adapter: NativeFullscreenAdapter): boolean {
return abandoned.get(adapter.document)?.has(adapter.target) ?? false
}
export function clearAbandonedFullscreen(adapter: NativeFullscreenAdapter): void {
abandoned.get(adapter.document)?.delete(adapter.target)
}
function exitNative(exit: () => unknown): void {
try {
void Promise.resolve(exit()).catch(() => {})
}
catch {}
}
function entriesFor(document: Document, elementProperty: string, changeEvent: string): WeakMap<Element, () => unknown> {
let entries = abandoned.get(document)
if (!entries) {
entries = new WeakMap()
abandoned.set(document, entries)
const pending = entries
// A document-lifetime guard for unabortable void APIs. Keys are weak and values
// contain only the native exit operation, never an ArtPlayer instance or scope.
document.addEventListener(changeEvent, () => {
const element = Reflect.get(document, elementProperty) as Element | null
const release = element && pending.get(element)
if (!release)
return
pending.delete(element)
exitNative(release)
})
}
return entries
}
export function abandonFullscreen(adapter: NativeFullscreenAdapter): void {
const { document, elementProperty, changeEvent, target, exit } = adapter
const entries = entriesFor(document, elementProperty, changeEvent)
entries.set(target, exit)
if (adapter.element === target) {
entries.delete(target)
exitNative(exit)
}
}
@@ -0,0 +1,38 @@
import type { NativeFullscreenAdapter } from './types'
import screenfull from '../libs/screenfull'
interface MethodMap {
requestFullscreen: string
exitFullscreen: string
fullscreenElement: string
fullscreenEnabled: string
fullscreenchange: string
fullscreenerror: string
}
function call(target: object, name: string, ...args: unknown[]): unknown {
const method: unknown = Reflect.get(target, name)
if (typeof method !== 'function')
throw new TypeError(`Fullscreen method ${name} is not available`)
return method.apply(target, args)
}
export function fullscreenAdapter(target: HTMLElement): NativeFullscreenAdapter | undefined {
// Reuse the audited vendor mapping, not its unscoped request-promise listeners.
const raw = screenfull.raw as MethodMap | false
const document = target.ownerDocument
if (!raw || !Reflect.get(document, raw.fullscreenEnabled))
return undefined
return {
document,
target,
get element(): Element | null {
return Reflect.get(document, raw.fullscreenElement) as Element | null
},
elementProperty: raw.fullscreenElement,
changeEvent: raw.fullscreenchange,
errorEvent: raw.fullscreenerror,
request: () => call(target, raw.requestFullscreen, undefined),
exit: () => call(document, raw.exitFullscreen),
}
}
@@ -0,0 +1,88 @@
import type ResourceScope from '../lifecycle/scope'
import type { NativeFullscreenAdapter } from './types'
import { listen } from '../lifecycle/resources'
import { abandonFullscreen, clearAbandonedFullscreen } from './fullscreen-abandoned'
export function requestFullscreen(adapter: NativeFullscreenAdapter, parent: ResourceScope, entering: boolean, lateCompletion: () => void, hasExited = () => adapter.element !== adapter.target): { promise: Promise<void>, cancel: () => void } {
const scope = parent.child()
let done = false
let cancelled = false
let returned = false
let promiseResult = false
let eventFailed = false
let resolve!: () => void
let reject!: (error: unknown) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
scope.add(() => {
if (!done) {
done = true
cancelled = true
resolve()
if (returned && !promiseResult && entering)
abandonFullscreen(adapter)
}
})
function finish(failed = false, error?: unknown) {
if (done)
return
done = true
try {
scope.dispose()
}
catch (cleanupError) {
if (!failed) {
failed = true
error = cleanupError
}
}
if (failed)
reject(error)
else
resolve()
}
const changed = () => {
if (returned && !promiseResult && (entering ? adapter.element === adapter.target : hasExited()))
finish()
}
listen(scope, adapter.document, adapter.changeEvent, changed)
listen(scope, adapter.document, adapter.errorEvent, (event) => {
if (event.target === adapter.target || event.target === adapter.document) {
eventFailed = true
if (returned && !promiseResult)
finish(true, new Error('Fullscreen request failed'))
}
})
if (!scope.closed) {
try {
// Invoke before yielding so the browser sees the caller's transient activation.
if (entering)
clearAbandonedFullscreen(adapter)
const result = entering ? adapter.request() : adapter.exit()
promiseResult = Boolean(result && typeof (result as PromiseLike<unknown>).then === 'function')
returned = true
if (cancelled && !promiseResult && entering)
abandonFullscreen(adapter)
if (promiseResult) {
void Promise.resolve(result).then(() => {
if (cancelled)
lateCompletion()
else
finish()
}, error => finish(true, error)).catch(() => {})
}
else if (eventFailed) {
finish(true, new Error('Fullscreen request failed'))
}
else {
changed()
}
}
catch (error) {
finish(true, error)
}
}
return { promise, cancel: () => scope.dispose() }
}
@@ -0,0 +1,51 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { listen } from '../lifecycle/resources'
import { clampMini, miniGeometry } from './mini-layout'
export function miniDrag(art: MiniHost, element: HTMLElement, scope: ResourceScope, active: () => boolean): () => void {
let dragging = false
let startX = 0
let startY = 0
const cancel = (): undefined => {
dragging = false
element.classList.remove('art-mini-dragging')
element.style.transform = ''
}
listen(scope, element, 'mousedown', (event) => {
const mouse = event as MouseEvent
if (mouse.button !== 0 || !active())
return
dragging = true
startX = mouse.clientX
startY = mouse.clientY
})
const move = (event: MouseEvent) => {
if (!scope.closed && dragging && active()) {
element.classList.add('art-mini-dragging')
element.style.transform = `translate(${event.clientX - startX}px, ${event.clientY - startY}px)`
}
}
const end = () => {
if (scope.closed || !dragging || !active())
return
const rect = element.getBoundingClientRect()
const position = clampMini(rect.left, rect.top, miniGeometry(element))
cancel()
element.style.left = `${position.left}px`
element.style.top = `${position.top}px`
art.storage.set('left', position.left)
if (!scope.closed)
art.storage.set('top', position.top)
}
art.on('document:mousemove', move)
scope.add(() => {
art.off('document:mousemove', move)
})
art.on('document:mouseup', end)
scope.add(() => {
art.off('document:mouseup', end)
})
scope.add(cancel)
return cancel
}
@@ -0,0 +1,24 @@
export interface MiniGeometry {
width: number
height: number
viewportWidth: number
viewportHeight: number
}
export function clampMini(left: number, top: number, geometry: MiniGeometry): { left: number, top: number } {
const maxLeft = Math.max(0, geometry.viewportWidth - geometry.width)
const maxTop = Math.max(0, geometry.viewportHeight - geometry.height)
return { left: Math.max(0, Math.min(left, maxLeft)), top: Math.max(0, Math.min(top, maxTop)) }
}
export function miniPosition(left: unknown, top: unknown, geometry: MiniGeometry): { left: number, top: number, reset: boolean } {
if (typeof left === 'number' && Number.isFinite(left) && typeof top === 'number' && Number.isFinite(top)
&& left >= 0 && top >= 0 && left + geometry.width <= geometry.viewportWidth && top + geometry.height <= geometry.viewportHeight) { return { left, top, reset: false } }
return { ...clampMini(geometry.viewportWidth - geometry.width - 50, geometry.viewportHeight - geometry.height - 50, geometry), reset: true }
}
export function miniGeometry(element: HTMLElement): MiniGeometry {
const { width, height } = element.getBoundingClientRect()
const view = element.ownerDocument.defaultView!
return { width, height, viewportWidth: view.innerWidth, viewportHeight: view.innerHeight }
}
@@ -0,0 +1,79 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { listen } from '../lifecycle/resources'
import { silencePromise } from '../utils/error'
import { miniDrag } from './mini-drag'
export interface MiniView {
element: HTMLElement
cancelDrag: () => void
fresh: boolean
}
export function createMiniView(art: MiniHost, parent: ResourceScope, active: () => boolean, hide: () => void): MiniView | undefined {
const scope = parent.child()
if (scope.closed)
return
const existing = art.template.$mini
if (existing) {
const display = existing.style.display
scope.add(() => {
existing.style.display = display
})
return { element: existing, cancelDrag: () => {}, fresh: false }
}
const document = art.template.$player.ownerDocument
const element = document.createElement('div')
element.className = 'art-mini-popup'
scope.add(() => {
element.remove()
})
try {
const close = document.createElement('div')
close.className = 'art-mini-close'
close.append(art.icons.close)
if (scope.closed)
return
const state = document.createElement('div')
state.className = 'art-mini-state'
const play = art.icons.play
const pause = art.icons.pause
if (scope.closed)
return
state.append(play, pause)
element.append(close, state)
listen(scope, close, 'click', hide)
listen(scope, play, 'click', () => {
silencePromise(art.play())
})
listen(scope, pause, 'click', () => {
art.pause()
})
const update = () => {
if (scope.closed)
return
play.style.display = art.playing ? 'none' : 'flex'
pause.style.display = art.playing ? 'flex' : 'none'
}
update()
for (const name of ['video:playing', 'video:pause', 'video:timeupdate'] as const) {
art.on(name, update)
scope.add(() => {
art.off(name, update)
})
}
const cancelDrag = miniDrag(art, element, scope, active)
if (scope.closed)
return
art.template.$mini = element
document.body.append(element)
if (!scope.closed)
return { element, cancelDrag, fresh: true }
}
catch (error) {
scope.dispose()
if (art.template.$mini === element)
delete art.template.$mini
throw error
}
}
+142
View File
@@ -0,0 +1,142 @@
import type { MiniView } from './mini-view'
import type { Placement } from './placement'
import type { MiniHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { miniGeometry, miniPosition } from './mini-layout'
import { createMiniView } from './mini-view'
import { capturePlacement, restorePlacement } from './placement'
export function mini(art: MiniHost): PropertyDescriptor {
const { $player, $video } = art.template
const scope = getScope(art).child()
let view: MiniView | undefined
let placement: Placement | undefined
let revision = 0
let creating = false
let queued = false
const active = () => !isClosing(art) && $player.classList.contains('art-mini')
function restore() {
if (placement) {
const saved = placement
const current = revision
placement = undefined
try {
restorePlacement(saved)
}
catch (error) {
if (current === revision && !placement)
placement = saved
throw error
}
}
}
function hide() {
if (isClosing(art))
return
const current = ++revision
queued = false
restore()
if (isClosing(art) || current !== revision)
return
view?.cancelDrag()
$player.classList.remove('art-mini')
if (art.template.$mini) {
art.template.$mini.style.display = 'none'
art.emit('mini', false)
}
}
scope.add(() => {
revision++
try {
restore()
}
finally {
view?.cancelDrag()
$player.classList.remove('art-mini')
}
})
function show(): void {
if (isClosing(art))
return
const current = ++revision
if (creating) {
queued = true
return
}
art.state = 'mini'
if (isClosing(art) || current !== revision)
return
placement ??= capturePlacement($video)
$player.classList.add('art-mini')
try {
if (!view) {
creating = true
try {
view = createMiniView(art, scope, active, hide)
}
finally {
creating = false
}
}
if (queued) {
queued = false
show()
return
}
if (!view || isClosing(art))
return
if (current !== revision) {
if (!active())
view.element.style.display = 'none'
return
}
if (view.fresh)
view.element.prepend($video)
else
view.element.append($video)
if (isClosing(art) || current !== revision)
return
view.element.style.display = view.fresh ? '' : 'flex'
view.fresh = false
const top = art.storage.get('top')
const left = art.storage.get('left')
const position = miniPosition(left, top, miniGeometry(view.element))
if (isClosing(art) || current !== revision)
return
view.element.style.left = `${position.left}px`
view.element.style.top = `${position.top}px`
if (position.reset) {
art.storage.set('top', position.top)
if (isClosing(art) || current !== revision)
return
art.storage.set('left', position.left)
}
if (!isClosing(art) && current === revision)
art.emit('mini', true)
}
catch (error) {
const pendingReentry = queued
queued = false
if (!isClosing(art) && (current === revision || pendingReentry)) {
restore()
$player.classList.remove('art-mini')
if (view)
view.element.style.display = 'none'
}
throw error
}
}
return {
get: active,
set(value: boolean): void {
if (value)
show()
else
hide()
},
}
}
@@ -0,0 +1,106 @@
import type { FullscreenHost, NativeFullscreenAdapter } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { hasAbandonedFullscreen } from './fullscreen-abandoned'
import { requestFullscreen } from './fullscreen-request'
export function nativeFullscreen(art: FullscreenHost, adapter: NativeFullscreenAdapter): PropertyDescriptor {
const scope = getScope(art).child()
const { $player, $video } = art.template
const ownsFullscreen = () => adapter.element === $player || adapter.element === $video
let last = ownsFullscreen()
let revision = 0
let desired = last
let pending: ReturnType<typeof requestFullscreen> | undefined
let pendingEntry = false
let cancelledEntry = false
let requesting = false
function exitOwned() {
if (!ownsFullscreen())
return
try {
void Promise.resolve(adapter.exit()).catch(() => {})
}
catch {}
}
scope.add(() => {
revision += 1
desired = false
$player.classList.remove('art-fullscreen')
exitOwned()
})
listen(scope, adapter.document, adapter.changeEvent, () => {
const value = ownsFullscreen()
if (value && hasAbandonedFullscreen(adapter))
return
if (value && cancelledEntry) {
exitOwned()
return
}
if (value === last || isClosing(art))
return
last = value
const current = revision
const stale = () => isClosing(art) || ownsFullscreen() !== value || (current !== revision && desired !== value)
art.emit('fullscreen', value)
if (stale())
return
if (value)
art.state = 'fullscreen'
if (stale())
return
$player.classList.toggle('art-fullscreen', value)
art.emit('resize')
})
listen(scope, adapter.document, adapter.errorEvent, (event) => {
const ownEvent = event.target === $player || event.target === $video
|| (event.target === adapter.document && (requesting || pending !== undefined || ownsFullscreen()))
if (ownEvent && !isClosing(art))
art.emit('fullscreenError', event)
})
return {
get: () => !isClosing(art) && ownsFullscreen(),
set(value: boolean): Promise<void> {
if (isClosing(art))
return Promise.resolve()
const current = ++revision
desired = Boolean(value)
if (desired)
cancelledEntry = false
else if (pendingEntry)
cancelledEntry = true
pending?.cancel()
pending = undefined
pendingEntry = false
if (!desired && !ownsFullscreen())
return Promise.resolve()
requesting = true
pendingEntry = desired
const operation = requestFullscreen(adapter, scope, desired, () => {
if (isClosing(art) || !desired)
exitOwned()
}, () => !ownsFullscreen())
requesting = false
if (revision === current)
pending = operation
else
operation.cancel()
const result = operation.promise.catch((error) => {
if (!isClosing(art) && revision === current)
art.notice.show = error
throw error
}).finally(() => {
if (revision === current) {
pending = undefined
pendingEntry = false
}
})
// Assignment cannot consume a setter return, but descriptor callers still can.
void result.catch(() => {})
return result
},
}
}
@@ -0,0 +1,98 @@
import type { PipHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
export function nativePip(art: PipHost): PropertyDescriptor {
const { $video } = art.template
const document = $video.ownerDocument
const scope = getScope(art).child()
const owns = () => document.pictureInPictureElement === $video
let last = owns()
let desired = last
let pending = false
let cancelled = false
let revision = 0
$video.disablePictureInPicture = false
function exitOwned() {
if (!owns())
return
try {
void document.exitPictureInPicture().catch(() => {})
}
catch {}
}
function observe(result: Promise<unknown>, entering: boolean, current: number) {
void result.then(() => {
if (entering && (isClosing(art) || !desired))
exitOwned()
}, (error) => {
if (!isClosing(art) && revision === current)
art.notice.show = error
}).finally(() => {
if (revision === current)
pending = false
}).catch(() => {})
}
scope.add(() => {
revision++
desired = false
exitOwned()
})
listen(scope, $video, 'enterpictureinpicture', () => {
if (cancelled) {
exitOwned()
return
}
if (!owns() || last)
return
const current = revision
art.state = 'pip'
if (!isClosing(art) && current === revision && owns()) {
last = true
art.emit('pip', true)
}
})
listen(scope, $video, 'leavepictureinpicture', () => {
if (!last || owns())
return
last = false
art.emit('pip', false)
})
return {
// Native callers historically receive the media element, not a boolean.
get: () => !isClosing(art) && owns() ? $video : null,
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
desired = Boolean(value)
if (desired)
cancelled = false
else if (pending)
cancelled = true
pending = false
if (desired) {
art.state = 'pip'
if (isClosing(art) || revision !== current)
return
pending = true
try {
// Native invocation stays on the user gesture stack; the setter stays void.
observe($video.requestPictureInPicture!(), true, current)
}
catch (error) {
if (revision === current)
pending = false
throw error
}
}
else if (owns()) {
observe(document.exitPictureInPicture(), false, current)
}
},
}
}
@@ -0,0 +1,87 @@
import type { OrientationHost, OrientationLock } from './orientation-types'
import { getScope, isClosing } from '../lifecycle/instance'
interface Request {
orientation: OrientationLock
cancelled: boolean
settled: boolean
}
// Per-screen ownership prevents an old instance from unlocking a newer request.
const owners = new WeakMap<OrientationLock, Request>()
const className = 'art-auto-orientation-fullscreen'
function unlock(request: Request): void {
if (owners.get(request.orientation) !== request)
return
try {
request.orientation.unlock()
}
catch {}
}
export function nativeOrientation(art: OrientationHost, needRotate: () => boolean): (state: boolean) => void {
const { $player } = art.template
let current: Request | undefined
function cancel(): undefined {
const request = current
current = undefined
$player.classList.remove(className)
if (request) {
request.cancelled = true
unlock(request)
if (request.settled && owners.get(request.orientation) === request)
owners.delete(request.orientation)
}
}
getScope(art).add(cancel)
return (state) => {
if (isClosing(art))
return
if (!state) {
cancel()
return
}
if (current && !current.cancelled && owners.get(current.orientation) === current)
return
const orientation = $player.ownerDocument.defaultView?.screen?.orientation as OrientationLock | undefined
if (typeof orientation?.lock !== 'function' || typeof orientation.unlock !== 'function' || !needRotate())
return
const request: Request = { orientation, cancelled: false, settled: false }
current = request
owners.set(orientation, request)
const active = () => !isClosing(art) && !request.cancelled && current === request && owners.get(orientation) === request
const failed = (error: unknown) => {
request.settled = true
const show = active()
if (owners.get(orientation) === request)
owners.delete(orientation)
if (current === request)
current = undefined
if (show) {
$player.classList.remove(className)
art.notice.show = error
}
}
try {
// Invoke before yielding; fullscreen entry already supplied the gesture.
const result = orientation.lock(orientation.type.startsWith('portrait') ? 'landscape' : 'portrait')
Promise.resolve(result).then(() => {
request.settled = true
if (active()) {
$player.classList.add(className)
}
else {
unlock(request)
if (owners.get(orientation) === request)
owners.delete(orientation)
}
}).catch(failed)
}
catch (error) {
failed(error)
}
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface OrientationHost extends SubscriptionHost<{ fullscreenWeb: [boolean], fullscreen: [boolean] }> {
constructor: { AUTO_ORIENTATION_TIME?: number }
template: { $player: HTMLElement, $video: { readonly videoWidth: number, readonly videoHeight: number } }
fullscreenWeb: boolean
isRotate: boolean
notice: { set show(value: unknown) }
emit: (name: 'resize') => unknown
}
export interface OrientationLock {
readonly type: string
lock: (mode: 'portrait' | 'landscape') => Promise<void>
unlock: () => void
}
@@ -0,0 +1,72 @@
import type { OrientationHost } from './orientation-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { getSafeAreaInsets } from '../utils'
const className = 'art-auto-orientation'
const properties = ['width', 'height', 'transform-origin', 'transform'] as const
export function webOrientation(art: OrientationHost, needRotate: () => boolean): (state: boolean) => void {
const { $player } = art.template
const scope = getScope(art)
let cancel = () => {}
let saved: { name: string, value: string, priority: string }[] | undefined
function clear(): undefined {
cancel()
cancel = () => {}
const previous = saved
saved = undefined
// fullscreenWeb restores its complete entry style before emitting false.
if (previous && art.fullscreenWeb) {
for (const { name, value, priority } of previous)
$player.style.setProperty(name, value, priority)
}
const wasRotated = $player.classList.contains(className)
$player.classList.remove(className)
if (wasRotated) {
art.isRotate = false
if (!isClosing(art))
art.emit('resize')
}
}
scope.add(clear)
function apply(emit: boolean): void {
if (!art.fullscreenWeb || !needRotate())
return
const insets = getSafeAreaInsets()
if (isClosing(art) || !art.fullscreenWeb)
return
const viewport = $player.ownerDocument.documentElement
saved ??= properties.map(name => ({ name, value: $player.style.getPropertyValue(name), priority: $player.style.getPropertyPriority(name) }))
$player.style.width = `${viewport.clientHeight - insets.top - insets.bottom}px`
$player.style.height = `${viewport.clientWidth - insets.left - insets.right}px`
$player.style.transformOrigin = '0 0'
$player.style.transform = `rotate(90deg) translate(${insets.top}px, -${viewport.clientWidth - insets.right}px)`
$player.classList.add(className)
art.isRotate = true
if (emit)
art.emit('resize')
}
return (state) => {
if (isClosing(art))
return
if (!state) {
clear()
return
}
if (saved) {
apply(false)
return
}
if (!needRotate())
return
cancel()
cancel = timeout(scope, () => {
if (!saved)
apply(true)
}, Number(art.constructor.AUTO_ORIENTATION_TIME ?? 0))
}
}
@@ -0,0 +1,19 @@
export interface Placement {
node: Node
parent: ParentNode | null
next: ChildNode | null
}
export function capturePlacement(node: Node): Placement {
return { node, parent: node.parentNode, next: node.nextSibling }
}
export function restorePlacement({ node, parent, next }: Placement): void {
if (!parent) {
node.parentNode?.removeChild(node)
return
}
const anchor = next?.parentNode === parent ? next : null
if (node.parentNode !== parent || node.nextSibling !== anchor)
parent.insertBefore(node, anchor)
}
@@ -0,0 +1,29 @@
import type { PlaybackNotice } from '../media/playback'
import type { Size } from './sizing'
interface MediaDimensions {
readonly videoWidth: number
readonly videoHeight: number
}
export interface AutoSizeHost {
template: { $container: HTMLElement, $player: HTMLElement, $video: MediaDimensions }
readonly width: number
readonly height: number
emit: (name: 'autoSize', size: Size) => unknown
}
export interface AutoHeightHost {
template: { $container: HTMLElement, $video: MediaDimensions }
emit: (name: 'autoHeight', height: number) => unknown
}
export interface AspectRatioHost extends PlaybackNotice {
template: { $player: HTMLElement, $video: HTMLElement }
emit: (name: 'aspectRatio', ratio: string) => unknown
}
export interface FlipHost extends PlaybackNotice {
template: { $player: HTMLElement }
emit: (name: 'flip', flip: string) => unknown
}
+24
View File
@@ -0,0 +1,24 @@
export interface Size {
width: number
height: number
}
export function positiveSize({ width, height }: Size): boolean {
return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0
}
export function containSize(container: Size, ratio: number): (Size & { axis: 'width' | 'height' }) | undefined {
if (!positiveSize(container) || !Number.isFinite(ratio) || ratio <= 0)
return
const result = container.width / container.height > ratio
? { width: ratio * container.height, height: container.height, axis: 'width' as const }
: { width: container.width, height: container.width / ratio, axis: 'height' as const }
return positiveSize(result) ? result : undefined
}
export function proportionalHeight(width: number, media: Size): number | undefined {
if (!Number.isFinite(width) || width <= 0 || !positiveSize(media))
return
const height = media.height * (width / media.width)
return Number.isFinite(height) && height > 0 ? height : undefined
}
+71
View File
@@ -0,0 +1,71 @@
import type { SubscriptionHost } from '../component/resources'
import type { OptionalMediaCapabilities } from '../media/types'
export interface WebFullscreenHost {
constructor: { FULLSCREEN_WEB_IN_BODY: boolean }
template: { $container: HTMLElement, $player: HTMLElement }
state: string
emit: (name: 'fullscreenWeb' | 'resize', ...args: unknown[]) => unknown
}
export interface WebFullscreenProperty {
fullscreenWeb: boolean
}
export interface FullscreenHost {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities }
state: string
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
emit: (name: 'fullscreen' | 'fullscreenError' | 'resize', ...args: unknown[]) => unknown
once: (name: 'video:loadedmetadata', listener: () => void) => unknown
on: (name: 'document:webkitfullscreenchange', listener: () => void) => unknown
off: (name: 'video:loadedmetadata' | 'document:webkitfullscreenchange', listener: () => void) => unknown
}
export interface NativeFullscreenAdapter {
document: Document
target: HTMLElement
readonly element: Element | null
elementProperty: string
changeEvent: string
errorEvent: string
request: () => unknown
exit: () => unknown
}
export interface PipHost {
template: { $video: HTMLElement & OptionalMediaCapabilities & { disablePictureInPicture?: boolean } }
state: string
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
emit: (name: 'pip', value: boolean) => unknown
}
export interface PipProperty {
get pip(): HTMLElement | null | boolean
set pip(value: boolean)
}
export interface MiniEvents {
'video:playing': []
'video:pause': []
'video:timeupdate': []
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
}
export interface MiniHost extends SubscriptionHost<MiniEvents> {
template: { $player: HTMLElement, $video: HTMLElement, $mini?: HTMLElement }
icons: { close: HTMLElement, play: HTMLElement, pause: HTMLElement }
storage: { get: (key: string) => unknown, set: (key: string, value: number) => unknown }
state: string
playing: boolean
play: () => unknown
pause: () => unknown
emit: (name: 'mini', value: boolean) => unknown
}
export interface MiniProperty {
mini: boolean
}
@@ -0,0 +1,40 @@
import type { OptionalMediaCapabilities } from '../media/types'
type Video = HTMLElement & OptionalMediaCapabilities
const abandoned = new WeakSet<Video>()
export function videoIsFullscreen(video: Video, observed = false): boolean {
if (typeof video.webkitPresentationMode === 'string')
return video.webkitPresentationMode === 'fullscreen'
if (typeof video.webkitDisplayingFullscreen === 'boolean')
return video.webkitDisplayingFullscreen
return video.ownerDocument.fullscreenElement === video || observed
}
export function clearAbandonedVideo(video: Video): void {
abandoned.delete(video)
video.removeEventListener('webkitbeginfullscreen', releaseAbandonedVideo)
video.removeEventListener('webkitpresentationmodechanged', releaseAbandonedVideo)
}
function releaseAbandonedVideo(this: Video, event: Event): void {
if (!abandoned.has(this) || !videoIsFullscreen(this, event.type === 'webkitbeginfullscreen'))
return
clearAbandonedVideo(this)
try {
this.webkitExitFullscreen?.()
}
catch {}
}
export function abandonVideo(video: Video): void {
abandoned.add(video)
// The listener is held by the video itself, with no instance or scope closure.
video.addEventListener('webkitbeginfullscreen', releaseAbandonedVideo)
video.addEventListener('webkitpresentationmodechanged', releaseAbandonedVideo)
}
export function isAbandonedVideo(video: Video): boolean {
return abandoned.has(video)
}
@@ -0,0 +1,87 @@
import type { FullscreenHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { abandonVideo, clearAbandonedVideo, isAbandonedVideo, videoIsFullscreen } from './video-fullscreen-state'
export function videoFullscreen(art: FullscreenHost): PropertyDescriptor {
const { $video } = art.template
const scope = getScope(art).child()
let observed = false
let last = videoIsFullscreen($video)
let desired = last
let pending = false
let revision = 0
const active = () => videoIsFullscreen($video, observed)
function changed(hint?: boolean) {
if (isClosing(art) || isAbandonedVideo($video))
return
if (typeof hint === 'boolean')
observed = hint
const value = active()
if (value)
pending = false
if (value === last)
return
last = value
const current = revision
const stale = () => isClosing(art) || active() !== value || (current !== revision && desired !== value)
art.emit('fullscreen', value)
if (stale())
return
if (value)
art.state = 'fullscreen'
if (!stale())
art.emit('resize')
}
scope.add(() => {
revision++
desired = false
if (pending)
abandonVideo($video)
if (active())
$video.webkitExitFullscreen?.()
})
listen(scope, $video, 'webkitbeginfullscreen', () => changed(true))
listen(scope, $video, 'webkitendfullscreen', () => changed(false))
listen(scope, $video, 'webkitpresentationmodechanged', () => changed())
const documentChanged = () => changed()
art.on('document:webkitfullscreenchange', documentChanged)
scope.add(() => {
art.off('document:webkitfullscreenchange', documentChanged)
})
return {
get: () => !isClosing(art) && active(),
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
desired = Boolean(value)
if (value) {
clearAbandonedVideo($video)
art.state = 'fullscreen'
if (isClosing(art) || current !== revision)
return
pending = true
try {
$video.webkitEnterFullscreen!()
}
catch (error) {
if (current === revision)
pending = false
throw error
}
}
else {
if (pending) {
pending = false
abandonVideo($video)
}
if (active())
$video.webkitExitFullscreen!()
}
},
}
}
@@ -0,0 +1,99 @@
import type { Placement } from './placement'
import type { WebFullscreenHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { capturePlacement, restorePlacement } from './placement'
interface Snapshot {
placement: Placement
style: string | null
}
export function webFullscreen(art: WebFullscreenHost): (value: boolean) => void {
const { $player } = art.template
let saved: Snapshot | undefined
let revision = 0
function restore(snapshot: Snapshot, active: () => boolean): boolean {
if (snapshot.style === null)
$player.removeAttribute('style')
else
$player.setAttribute('style', snapshot.style)
if (!active())
return false
$player.classList.remove('art-fullscreen-web')
if (!active())
return false
restorePlacement(snapshot.placement)
return active()
}
getScope(art).add(() => {
revision += 1
const snapshot = saved
saved = undefined
if (snapshot) {
try {
restore(snapshot, () => true)
}
catch (error) {
// Destruction cannot retry: detach only if restoration left our node outside its owner.
try {
if ($player.parentNode !== snapshot.placement.parent)
$player.parentNode?.removeChild($player)
}
catch (detachError) {
throw new ResourceCleanupError([error, detachError])
}
throw error
}
}
})
return (value) => {
if (isClosing(art))
return
const current = ++revision
const active = () => current === revision && !isClosing(art)
if (value) {
if (!saved)
saved = { placement: capturePlacement($player), style: $player.getAttribute('style') }
const snapshot = saved
try {
if (art.constructor.FULLSCREEN_WEB_IN_BODY && $player.parentNode !== document.body)
document.body.appendChild($player)
if (!active())
return
art.state = 'fullscreenWeb'
if (!active())
return
$player.style.width = '100%'
$player.style.height = '100%'
$player.classList.add('art-fullscreen-web')
}
catch (error) {
if (active() && restore(snapshot, active))
saved = undefined
throw error
}
if (!active())
return
art.emit('fullscreenWeb', true)
}
else {
if (saved) {
if (!restore(saved, active))
return
saved = undefined
}
else {
$player.classList.remove('art-fullscreen-web')
}
if (!active())
return
art.emit('fullscreenWeb', false)
}
if (active())
art.emit('resize')
}
}
@@ -0,0 +1,107 @@
import type { PipHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
type Video = PipHost['template']['$video']
const abandoned = new WeakSet<Video>()
const active = (video: Video) => video.webkitPresentationMode === 'picture-in-picture'
function clearAbandoned(video: Video) {
abandoned.delete(video)
video.removeEventListener('webkitpresentationmodechanged', releaseAbandoned)
video.removeEventListener('enterpictureinpicture', releaseAbandoned)
}
function releaseAbandoned(this: Video) {
if (!abandoned.has(this) || !active(this))
return
clearAbandoned(this)
try {
this.webkitSetPresentationMode?.('inline')
}
catch {}
}
function abandon(video: Video) {
abandoned.add(video)
video.addEventListener('webkitpresentationmodechanged', releaseAbandoned)
video.addEventListener('enterpictureinpicture', releaseAbandoned)
}
export function webkitPip(art: PipHost): PropertyDescriptor {
const { $video } = art.template
const scope = getScope(art).child()
let last = false
let pending = false
let revision = 0
let emissions = 0
$video.webkitSetPresentationMode!('inline')
function changed(force = false) {
if (isClosing(art) || abandoned.has($video))
return
const value = active($video)
if (value)
pending = false
if (value === last && !force)
return
const current = revision
if (value)
art.state = 'pip'
if (isClosing(art) || revision !== current || active($video) !== value)
return
last = value
emissions++
art.emit('pip', value)
}
scope.add(() => {
revision++
if (pending)
abandon($video)
if (active($video))
$video.webkitSetPresentationMode!('inline')
})
for (const name of ['webkitpresentationmodechanged', 'enterpictureinpicture', 'leavepictureinpicture'])
listen(scope, $video, name, () => changed())
return {
get: () => !isClosing(art) && active($video),
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
const before = emissions
if (value) {
if ($video.webkitSupportsPresentationMode?.('picture-in-picture') === false) {
art.notice.show = art.i18n.get('PIP Not Supported')
return
}
clearAbandoned($video)
art.state = 'pip'
if (isClosing(art) || revision !== current)
return
pending = true
try {
$video.webkitSetPresentationMode!('picture-in-picture')
}
catch (error) {
if (current === revision)
pending = false
throw error
}
}
else {
if (pending) {
pending = false
abandon($video)
}
if (active($video))
$video.webkitSetPresentationMode!('inline')
}
if (!isClosing(art) && current === revision && before === emissions && active($video) === Boolean(value))
changed(true)
},
}
}
@@ -1,47 +0,0 @@
import { def, setStyle } from '../utils'
export default function aspectRatioMix(art) {
const {
i18n,
notice,
template: { $video, $player },
} = art
def(art, 'aspectRatio', {
get() {
return $player.dataset.aspectRatio || 'default'
},
set(ratio) {
if (!ratio)
ratio = 'default'
if (ratio === 'default') {
setStyle($video, 'width', null)
setStyle($video, 'height', null)
setStyle($video, 'margin', null)
delete $player.dataset.aspectRatio
}
else {
const ratioArray = ratio.split(':').map(Number)
const { clientWidth, clientHeight } = $player
const playerRatio = clientWidth / clientHeight
const setupRatio = ratioArray[0] / ratioArray[1]
if (playerRatio > setupRatio) {
setStyle($video, 'width', `${setupRatio * clientHeight}px`)
setStyle($video, 'height', '100%')
setStyle($video, 'margin', '0 auto')
}
else {
setStyle($video, 'width', '100%')
setStyle($video, 'height', `${clientWidth / setupRatio}px`)
setStyle($video, 'margin', 'auto 0')
}
$player.dataset.aspectRatio = ratio
}
notice.show = `${i18n.get('Aspect Ratio')}: ${ratio === 'default' ? i18n.get('Default') : ratio}`
art.emit('aspectRatio', ratio)
},
})
}
@@ -0,0 +1,44 @@
import type { AspectRatioHost } from '../display/sizing-types'
import { containSize } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function aspectRatioMix(art: AspectRatioHost): void {
const { i18n, notice, template: { $video, $player } } = art
def(art, 'aspectRatio', {
get: () => $player.dataset.aspectRatio || 'default',
set(ratio: string) {
if (isClosing(art))
return
if (!ratio)
ratio = 'default'
if (ratio === 'default') {
$video.style.width = ''
$video.style.height = ''
$video.style.margin = ''
delete $player.dataset.aspectRatio
}
else {
// Keep the legacy split/Number coercion and dataset even for invalid ratios.
const parts = ratio.split(':').map(Number)
const size = containSize({ width: $player.clientWidth, height: $player.clientHeight }, parts[0]! / parts[1]!)
if (isClosing(art))
return
if (size?.axis === 'width') {
$video.style.width = `${size.width}px`
$video.style.height = '100%'
$video.style.margin = '0 auto'
}
else if (size) {
$video.style.width = '100%'
$video.style.height = `${size.height}px`
$video.style.margin = 'auto 0'
}
$player.dataset.aspectRatio = ratio
}
notice.show = `${i18n.get('Aspect Ratio')}: ${ratio === 'default' ? i18n.get('Default') : ratio}`
if (!isClosing(art))
art.emit('aspectRatio', ratio)
},
})
}
@@ -1,17 +0,0 @@
import { def, setStyle } from '../utils'
export default function autoHeightMix(art) {
const {
template: { $container, $video },
} = art
def(art, 'autoHeight', {
value() {
const { clientWidth } = $container
const { videoHeight, videoWidth } = $video
const height = videoHeight * (clientWidth / videoWidth)
setStyle($container, 'height', `${height}px`)
art.emit('autoHeight', height)
},
})
}
@@ -0,0 +1,19 @@
import type { AutoHeightHost } from '../display/sizing-types'
import { proportionalHeight } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function autoHeightMix(art: AutoHeightHost): void {
const { $container, $video } = art.template
def(art, 'autoHeight', {
value() {
if (isClosing(art))
return
const height = proportionalHeight($container.clientWidth, { width: $video.videoWidth, height: $video.videoHeight })
if (height === undefined || isClosing(art))
return
$container.style.height = `${height}px`
art.emit('autoHeight', height)
},
})
}
@@ -1,28 +0,0 @@
import { def, getRect, setStyle } from '../utils'
export default function autoSizeMix(art) {
const { $container, $player, $video } = art.template
def(art, 'autoSize', {
value() {
const { videoWidth, videoHeight } = $video
const { width, height } = getRect($container)
const videoRatio = videoWidth / videoHeight
const containerRatio = width / height
if (containerRatio > videoRatio) {
const percentage = ((height * videoRatio) / width) * 100
setStyle($player, 'width', `${percentage}%`)
setStyle($player, 'height', '100%')
}
else {
const percentage = (width / videoRatio / height) * 100
setStyle($player, 'width', '100%')
setStyle($player, 'height', `${percentage}%`)
}
art.emit('autoSize', {
width: art.width,
height: art.height,
})
},
})
}
@@ -0,0 +1,30 @@
import type { AutoSizeHost } from '../display/sizing-types'
import { containSize, positiveSize } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def, getRect } from '../utils'
export default function autoSizeMix(art: AutoSizeHost): void {
const { $container, $player, $video } = art.template
def(art, 'autoSize', {
value() {
if (isClosing(art))
return
const media = { width: $video.videoWidth, height: $video.videoHeight }
if (!positiveSize(media))
return
const container = getRect($container)
const size = containSize(container, media.width / media.height)
if (!size || isClosing(art))
return
if (size.axis === 'width') {
$player.style.width = `${(size.width / container.width) * 100}%`
$player.style.height = '100%'
}
else {
$player.style.width = '100%'
$player.style.height = `${(size.height / container.height) * 100}%`
}
art.emit('autoSize', { width: art.width, height: art.height })
},
})
}
-28
View File
@@ -1,28 +0,0 @@
import { capitalize, def } from '../utils'
export default function flipMix(art) {
const {
template: { $player },
i18n,
notice,
} = art
def(art, 'flip', {
get() {
return $player.dataset.flip || 'normal'
},
set(flip) {
if (!flip)
flip = 'normal'
if (flip === 'normal') {
delete $player.dataset.flip
}
else {
$player.dataset.flip = flip
}
notice.show = `${i18n.get('Video Flip')}: ${i18n.get(capitalize(flip))}`
art.emit('flip', flip)
},
})
}
+23
View File
@@ -0,0 +1,23 @@
import type { FlipHost } from '../display/sizing-types'
import { isClosing } from '../lifecycle/instance'
import { capitalize, def } from '../utils'
export default function flipMix(art: FlipHost): void {
const { template: { $player }, i18n, notice } = art
def(art, 'flip', {
get: () => $player.dataset.flip || 'normal',
set(flip: string) {
if (isClosing(art))
return
if (!flip)
flip = 'normal'
if (flip === 'normal')
delete $player.dataset.flip
else
$player.dataset.flip = flip
notice.show = `${i18n.get('Video Flip')}: ${i18n.get(capitalize(flip))}`
if (!isClosing(art))
art.emit('flip', flip)
},
})
}
@@ -1,88 +0,0 @@
import screenfull from '../libs/screenfull'
import { addClass, def, get, removeClass } from '../utils'
export default function fullscreenMix(art) {
const {
i18n,
notice,
template: { $video, $player },
} = art
const nativeScreenfull = (art) => {
screenfull.on('change', () => {
art.emit('fullscreen', screenfull.isFullscreen)
if (screenfull.isFullscreen) {
art.state = 'fullscreen'
addClass($player, 'art-fullscreen')
}
else {
removeClass($player, 'art-fullscreen')
}
art.emit('resize')
})
screenfull.on('error', (event) => {
art.emit('fullscreenError', event)
})
def(art, 'fullscreen', {
get() {
return screenfull.isFullscreen
},
async set(value) {
if (value) {
await screenfull.request($player)
}
else {
await screenfull.exit()
}
},
})
}
const webkitScreenfull = (art) => {
art.on('document:webkitfullscreenchange', () => {
art.emit('fullscreen', art.fullscreen)
art.emit('resize')
})
def(art, 'fullscreen', {
get() {
return document.fullscreenElement === $video
},
set(value) {
if (value) {
art.state = 'fullscreen'
$video.webkitEnterFullscreen()
}
else {
$video.webkitExitFullscreen()
}
},
})
}
art.once('video:loadedmetadata', () => {
if (screenfull.isEnabled) {
nativeScreenfull(art)
}
else if ($video.webkitSupportsFullscreen) {
webkitScreenfull(art)
}
else {
def(art, 'fullscreen', {
get() {
return false
},
set() {
notice.show = i18n.get('Fullscreen Not Supported')
},
})
}
// Asynchronous setting
def(art, 'fullscreen', get(art, 'fullscreen'))
})
}
@@ -0,0 +1,35 @@
import type { FullscreenHost } from '../display/types'
import { fullscreenAdapter } from '../display/fullscreen-adapter'
import { nativeFullscreen } from '../display/native-fullscreen'
import { videoFullscreen } from '../display/video-fullscreen'
import { getScope, isClosing } from '../lifecycle/instance'
import { def, get } from '../utils'
export default function fullscreenMix(art: FullscreenHost): void {
const { i18n, notice, template: { $video, $player } } = art
const metadata = () => {
if (isClosing(art))
return
const adapter = fullscreenAdapter($player)
if (adapter) {
def(art, 'fullscreen', nativeFullscreen(art, adapter))
}
else if ($video.webkitSupportsFullscreen && typeof $video.webkitEnterFullscreen === 'function' && typeof $video.webkitExitFullscreen === 'function') {
def(art, 'fullscreen', videoFullscreen(art))
}
else {
def(art, 'fullscreen', {
get: () => false,
set() {
if (!isClosing(art))
notice.show = i18n.get('Fullscreen Not Supported')
},
})
}
def(art, 'fullscreen', get(art, 'fullscreen')!)
}
art.once('video:loadedmetadata', metadata)
getScope(art).add(() => {
art.off('video:loadedmetadata', metadata)
})
}
@@ -1,41 +0,0 @@
import { addClass, append, def, hasClass, removeClass, setStyle } from '../utils'
export default function fullscreenWebMix(art) {
const {
constructor,
template: { $container, $player },
} = art
let cssText = ''
def(art, 'fullscreenWeb', {
get() {
return hasClass($player, 'art-fullscreen-web')
},
set(value) {
if (value) {
cssText = $player.style.cssText
if (constructor.FULLSCREEN_WEB_IN_BODY) {
append(document.body, $player)
}
art.state = 'fullscreenWeb'
setStyle($player, 'width', '100%')
setStyle($player, 'height', '100%')
addClass($player, 'art-fullscreen-web')
art.emit('fullscreenWeb', true)
}
else {
if (constructor.FULLSCREEN_WEB_IN_BODY) {
append($container, $player)
}
if (cssText) {
$player.style.cssText = cssText
cssText = ''
}
removeClass($player, 'art-fullscreen-web')
art.emit('fullscreenWeb', false)
}
art.emit('resize')
},
})
}
@@ -0,0 +1,13 @@
import type { WebFullscreenHost, WebFullscreenProperty } from '../display/types'
import { webFullscreen } from '../display/web-fullscreen'
import { def } from '../utils'
export default function fullscreenWebMix(art: WebFullscreenHost): asserts art is WebFullscreenHost & WebFullscreenProperty {
const setFullscreen = webFullscreen(art)
def(art, 'fullscreenWeb', {
get(): boolean {
return art.template.$player.classList.contains('art-fullscreen-web')
},
set: setFullscreen,
})
}
-134
View File
@@ -1,134 +0,0 @@
import { addClass, append, createElement, def, getRect, hasClass, isInViewport, removeClass, setStyle, silencePromise } from '../utils'
export default function miniMix(art) {
const {
icons,
proxy,
storage,
template: { $player, $video },
} = art
let isDragging = false
let lastPageX = 0
let lastPageY = 0
function hideMini() {
const { $mini } = art.template
if ($mini) {
removeClass($player, 'art-mini')
setStyle($mini, 'display', 'none')
$player.prepend($video)
art.emit('mini', false)
}
}
function initState($play, $pause) {
if (art.playing) {
setStyle($play, 'display', `none`)
setStyle($pause, 'display', `flex`)
}
else {
setStyle($play, 'display', `flex`)
setStyle($pause, 'display', `none`)
}
}
function createMini() {
const { $mini } = art.template
if ($mini) {
append($mini, $video)
return setStyle($mini, 'display', 'flex')
}
else {
const $mini = createElement('div')
addClass($mini, 'art-mini-popup')
append(document.body, $mini)
art.template.$mini = $mini
append($mini, $video)
const $close = append($mini, `<div class="art-mini-close"></div>`)
append($close, icons.close)
proxy($close, 'click', hideMini)
const $state = append($mini, `<div class="art-mini-state"></div>`)
const $play = append($state, icons.play)
const $pause = append($state, icons.pause)
proxy($play, 'click', () => silencePromise(art.play()))
proxy($pause, 'click', () => art.pause())
initState($play, $pause)
art.on('video:playing', () => initState($play, $pause))
art.on('video:pause', () => initState($play, $pause))
art.on('video:timeupdate', () => initState($play, $pause))
proxy($mini, 'mousedown', (event) => {
isDragging = event.button === 0
lastPageX = event.pageX
lastPageY = event.pageY
})
art.on('document:mousemove', (event) => {
if (isDragging) {
addClass($mini, 'art-mini-dragging')
const x = event.pageX - lastPageX
const y = event.pageY - lastPageY
setStyle($mini, 'transform', `translate(${x}px, ${y}px)`)
}
})
art.on('document:mouseup', () => {
if (isDragging) {
isDragging = false
removeClass($mini, 'art-mini-dragging')
const rect = getRect($mini)
storage.set('left', rect.left)
storage.set('top', rect.top)
setStyle($mini, 'left', `${rect.left}px`)
setStyle($mini, 'top', `${rect.top}px`)
setStyle($mini, 'transform', null)
}
})
return $mini
}
}
function initMini() {
const { $mini } = art.template
const rect = getRect($mini)
const top = window.innerHeight - rect.height - 50
const left = window.innerWidth - rect.width - 50
storage.set('top', top)
storage.set('left', left)
setStyle($mini, 'top', `${top}px`)
setStyle($mini, 'left', `${left}px`)
}
def(art, 'mini', {
get() {
return hasClass($player, 'art-mini')
},
set(value) {
if (value) {
art.state = 'mini'
addClass($player, 'art-mini')
const $mini = createMini()
const top = storage.get('top')
const left = storage.get('left')
if (typeof top === 'number' && typeof left === 'number') {
setStyle($mini, 'top', `${top}px`)
setStyle($mini, 'left', `${left}px`)
if (!isInViewport($mini)) {
initMini()
}
}
else {
initMini()
}
art.emit('mini', true)
}
else {
hideMini()
}
},
})
}
+7
View File
@@ -0,0 +1,7 @@
import type { MiniHost, MiniProperty } from '../display/types'
import { mini } from '../display/mini'
import { def } from '../utils'
export default function miniMix(art: MiniHost): asserts art is MiniHost & MiniProperty {
def(art, 'mini', mini(art))
}
-87
View File
@@ -1,87 +0,0 @@
import { def } from '../utils'
function nativePip(art) {
const {
template: { $video },
proxy,
notice,
} = art
$video.disablePictureInPicture = false
def(art, 'pip', {
get() {
return document.pictureInPictureElement
},
set(value) {
if (value) {
art.state = 'pip'
$video.requestPictureInPicture().catch((err) => {
notice.show = err
throw err
})
}
else {
document.exitPictureInPicture().catch((err) => {
notice.show = err
throw err
})
}
},
})
proxy($video, 'enterpictureinpicture', () => {
art.emit('pip', true)
})
proxy($video, 'leavepictureinpicture', () => {
art.emit('pip', false)
})
}
function webkitPip(art) {
const { $video } = art.template
$video.webkitSetPresentationMode('inline')
def(art, 'pip', {
get() {
return $video.webkitPresentationMode === 'picture-in-picture'
},
set(value) {
if (value) {
art.state = 'pip'
$video.webkitSetPresentationMode('picture-in-picture')
art.emit('pip', true)
}
else {
$video.webkitSetPresentationMode('inline')
art.emit('pip', false)
}
},
})
}
export default function pipMix(art) {
const {
i18n,
notice,
template: { $video },
} = art
if (document.pictureInPictureEnabled) {
nativePip(art)
}
else if ($video.webkitSupportsPresentationMode) {
webkitPip(art)
}
else {
def(art, 'pip', {
get() {
return false
},
set() {
notice.show = i18n.get('PIP Not Supported')
},
})
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { PipHost, PipProperty } from '../display/types'
import { nativePip } from '../display/native-pip'
import { webkitPip } from '../display/webkit-pip'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function pipMix(art: PipHost): asserts art is PipHost & PipProperty {
const { i18n, notice, template: { $video } } = art
const document = $video.ownerDocument
if (document.pictureInPictureEnabled && typeof $video.requestPictureInPicture === 'function' && typeof document.exitPictureInPicture === 'function') {
def(art, 'pip', nativePip(art))
}
else if (typeof $video.webkitSupportsPresentationMode === 'function' && typeof $video.webkitSetPresentationMode === 'function') {
def(art, 'pip', webkitPip(art))
}
else {
def(art, 'pip', {
get: () => false,
set() {
if (!isClosing(art))
notice.show = i18n.get('PIP Not Supported')
},
})
}
}
@@ -1,101 +0,0 @@
import { addClass, getSafeAreaInsets, hasClass, removeClass, setStyle } from '../utils'
export default function autoOrientation(art) {
const {
notice,
constructor,
template: { $player, $video },
} = art
const WEB_CLASS = 'art-auto-orientation'
const FS_CLASS = 'art-auto-orientation-fullscreen'
let fsLocked = false
function applyWebRotate() {
const insets = getSafeAreaInsets()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
const safeWidth = viewHeight - insets.top - insets.bottom
const safeHeight = viewWidth - insets.left - insets.right
setStyle($player, 'width', `${safeWidth}px`)
setStyle($player, 'height', `${safeHeight}px`)
setStyle($player, 'transform-origin', '0 0')
setStyle($player, 'transform', `rotate(90deg) translate(${insets.top}px, -${viewWidth - insets.right}px)`)
addClass($player, WEB_CLASS)
art.isRotate = true
art.emit('resize')
}
function clearWebRotate() {
setStyle($player, 'width', '')
setStyle($player, 'height', '')
setStyle($player, 'transform-origin', '')
setStyle($player, 'transform', '')
removeClass($player, WEB_CLASS)
art.isRotate = false
art.emit('resize')
}
function needRotate() {
const { videoWidth, videoHeight } = $video
const vw = document.documentElement.clientWidth
const vh = document.documentElement.clientHeight
return (videoWidth > videoHeight && vw < vh) || (videoWidth < videoHeight && vw > vh)
}
art.on('fullscreenWeb', (state) => {
if (state) {
if (needRotate()) {
const delay = Number(constructor.AUTO_ORIENTATION_TIME ?? 0)
setTimeout(() => {
if (art.fullscreenWeb && !hasClass($player, WEB_CLASS)) {
applyWebRotate()
}
}, delay)
}
}
else {
if (hasClass($player, WEB_CLASS))
clearWebRotate()
}
})
art.on('fullscreen', async (state) => {
const canLock = !!screen?.orientation?.lock
if (state) {
if (canLock && needRotate()) {
try {
const last = screen.orientation.type
const opposite = last.startsWith('portrait') ? 'landscape' : 'portrait'
await screen.orientation.lock(opposite)
fsLocked = true
addClass($player, FS_CLASS)
}
catch (err) {
fsLocked = false
notice.show = err
}
}
}
else {
if (hasClass($player, FS_CLASS)) {
removeClass($player, FS_CLASS)
}
if (canLock && fsLocked) {
try {
screen.orientation.unlock()
}
catch { }
fsLocked = false
}
}
})
return {
name: 'autoOrientation',
get state() {
return hasClass($player, WEB_CLASS)
},
}
}
@@ -0,0 +1,30 @@
import type { OrientationHost } from '../display/orientation-types'
import { nativeOrientation } from '../display/orientation-native'
import { webOrientation } from '../display/orientation-web'
import { positiveSize } from '../display/sizing'
import { getScope } from '../lifecycle/instance'
export default function autoOrientation(art: OrientationHost): { name: string, readonly state: boolean } {
const { $player, $video } = art.template
const needRotate = () => {
const media = { width: $video.videoWidth, height: $video.videoHeight }
const viewport = $player.ownerDocument.documentElement
const view = { width: viewport.clientWidth, height: viewport.clientHeight }
return positiveSize(media) && positiveSize(view)
&& ((media.width > media.height && view.width < view.height) || (media.width < media.height && view.width > view.height))
}
const web = webOrientation(art, needRotate)
const native = nativeOrientation(art, needRotate)
art.on('fullscreenWeb', web)
art.on('fullscreen', native)
getScope(art).add(() => {
art.off('fullscreenWeb', web)
art.off('fullscreen', native)
})
return {
name: 'autoOrientation',
get state() {
return $player.classList.contains('art-auto-orientation')
},
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
import type { BuiltinRegistry, PluginHost } from './types'
import type { BuiltinRegistry, PluginFactory, PluginHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import autoOrientation from './autoOrientation'
@@ -15,8 +15,10 @@ export default function installBuiltins<Host extends PluginHost<Host>>(registry:
registry.add(lock)
if (!isClosing(registry.art) && option.autoPlayback && !option.isLive)
registry.add(autoPlayback)
if (!isClosing(registry.art) && option.autoOrientation && isMobile)
registry.add(autoOrientation)
if (!isClosing(registry.art) && option.autoOrientation && isMobile) {
// The JS constructor supplies the complete host before builtin installation.
registry.add(autoOrientation as unknown as PluginFactory<Host>)
}
if (!isClosing(registry.art) && option.fastForward && isMobile && !option.isLive)
registry.add(fastForward)
}
+2
View File
@@ -3,6 +3,8 @@
z-index: 9999;
width: 320px;
height: 180px;
max-width: 100vw;
max-height: 100vh;
background: #000;
border-radius: var(--art-border-radius);
cursor: move;