mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-20 04:01:20 -08:00
refactor(core): CORE-16 type display modes and own fullscreen lifetimes
This commit is contained in:
1 parent
1b83081e8f
commit
88aef830df
81 files changed
+33897
-1503
No files matched your search
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -67,4 +67,4 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var 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
@@ -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
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user