mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 05:41: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
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user