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

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

No files matched your search

@@ -0,0 +1,48 @@
import type { NativeFullscreenAdapter } from './types'
const abandoned = new WeakMap<Document, WeakMap<Element, () => unknown>>()
export function hasAbandonedFullscreen(adapter: NativeFullscreenAdapter): boolean {
return abandoned.get(adapter.document)?.has(adapter.target) ?? false
}
export function clearAbandonedFullscreen(adapter: NativeFullscreenAdapter): void {
abandoned.get(adapter.document)?.delete(adapter.target)
}
function exitNative(exit: () => unknown): void {
try {
void Promise.resolve(exit()).catch(() => {})
}
catch {}
}
function entriesFor(document: Document, elementProperty: string, changeEvent: string): WeakMap<Element, () => unknown> {
let entries = abandoned.get(document)
if (!entries) {
entries = new WeakMap()
abandoned.set(document, entries)
const pending = entries
// A document-lifetime guard for unabortable void APIs. Keys are weak and values
// contain only the native exit operation, never an ArtPlayer instance or scope.
document.addEventListener(changeEvent, () => {
const element = Reflect.get(document, elementProperty) as Element | null
const release = element && pending.get(element)
if (!release)
return
pending.delete(element)
exitNative(release)
})
}
return entries
}
export function abandonFullscreen(adapter: NativeFullscreenAdapter): void {
const { document, elementProperty, changeEvent, target, exit } = adapter
const entries = entriesFor(document, elementProperty, changeEvent)
entries.set(target, exit)
if (adapter.element === target) {
entries.delete(target)
exitNative(exit)
}
}
@@ -0,0 +1,38 @@
import type { NativeFullscreenAdapter } from './types'
import screenfull from '../libs/screenfull'
interface MethodMap {
requestFullscreen: string
exitFullscreen: string
fullscreenElement: string
fullscreenEnabled: string
fullscreenchange: string
fullscreenerror: string
}
function call(target: object, name: string, ...args: unknown[]): unknown {
const method: unknown = Reflect.get(target, name)
if (typeof method !== 'function')
throw new TypeError(`Fullscreen method ${name} is not available`)
return method.apply(target, args)
}
export function fullscreenAdapter(target: HTMLElement): NativeFullscreenAdapter | undefined {
// Reuse the audited vendor mapping, not its unscoped request-promise listeners.
const raw = screenfull.raw as MethodMap | false
const document = target.ownerDocument
if (!raw || !Reflect.get(document, raw.fullscreenEnabled))
return undefined
return {
document,
target,
get element(): Element | null {
return Reflect.get(document, raw.fullscreenElement) as Element | null
},
elementProperty: raw.fullscreenElement,
changeEvent: raw.fullscreenchange,
errorEvent: raw.fullscreenerror,
request: () => call(target, raw.requestFullscreen, undefined),
exit: () => call(document, raw.exitFullscreen),
}
}
@@ -0,0 +1,88 @@
import type ResourceScope from '../lifecycle/scope'
import type { NativeFullscreenAdapter } from './types'
import { listen } from '../lifecycle/resources'
import { abandonFullscreen, clearAbandonedFullscreen } from './fullscreen-abandoned'
export function requestFullscreen(adapter: NativeFullscreenAdapter, parent: ResourceScope, entering: boolean, lateCompletion: () => void, hasExited = () => adapter.element !== adapter.target): { promise: Promise<void>, cancel: () => void } {
const scope = parent.child()
let done = false
let cancelled = false
let returned = false
let promiseResult = false
let eventFailed = false
let resolve!: () => void
let reject!: (error: unknown) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
scope.add(() => {
if (!done) {
done = true
cancelled = true
resolve()
if (returned && !promiseResult && entering)
abandonFullscreen(adapter)
}
})
function finish(failed = false, error?: unknown) {
if (done)
return
done = true
try {
scope.dispose()
}
catch (cleanupError) {
if (!failed) {
failed = true
error = cleanupError
}
}
if (failed)
reject(error)
else
resolve()
}
const changed = () => {
if (returned && !promiseResult && (entering ? adapter.element === adapter.target : hasExited()))
finish()
}
listen(scope, adapter.document, adapter.changeEvent, changed)
listen(scope, adapter.document, adapter.errorEvent, (event) => {
if (event.target === adapter.target || event.target === adapter.document) {
eventFailed = true
if (returned && !promiseResult)
finish(true, new Error('Fullscreen request failed'))
}
})
if (!scope.closed) {
try {
// Invoke before yielding so the browser sees the caller's transient activation.
if (entering)
clearAbandonedFullscreen(adapter)
const result = entering ? adapter.request() : adapter.exit()
promiseResult = Boolean(result && typeof (result as PromiseLike<unknown>).then === 'function')
returned = true
if (cancelled && !promiseResult && entering)
abandonFullscreen(adapter)
if (promiseResult) {
void Promise.resolve(result).then(() => {
if (cancelled)
lateCompletion()
else
finish()
}, error => finish(true, error)).catch(() => {})
}
else if (eventFailed) {
finish(true, new Error('Fullscreen request failed'))
}
else {
changed()
}
}
catch (error) {
finish(true, error)
}
}
return { promise, cancel: () => scope.dispose() }
}
@@ -0,0 +1,51 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { listen } from '../lifecycle/resources'
import { clampMini, miniGeometry } from './mini-layout'
export function miniDrag(art: MiniHost, element: HTMLElement, scope: ResourceScope, active: () => boolean): () => void {
let dragging = false
let startX = 0
let startY = 0
const cancel = (): undefined => {
dragging = false
element.classList.remove('art-mini-dragging')
element.style.transform = ''
}
listen(scope, element, 'mousedown', (event) => {
const mouse = event as MouseEvent
if (mouse.button !== 0 || !active())
return
dragging = true
startX = mouse.clientX
startY = mouse.clientY
})
const move = (event: MouseEvent) => {
if (!scope.closed && dragging && active()) {
element.classList.add('art-mini-dragging')
element.style.transform = `translate(${event.clientX - startX}px, ${event.clientY - startY}px)`
}
}
const end = () => {
if (scope.closed || !dragging || !active())
return
const rect = element.getBoundingClientRect()
const position = clampMini(rect.left, rect.top, miniGeometry(element))
cancel()
element.style.left = `${position.left}px`
element.style.top = `${position.top}px`
art.storage.set('left', position.left)
if (!scope.closed)
art.storage.set('top', position.top)
}
art.on('document:mousemove', move)
scope.add(() => {
art.off('document:mousemove', move)
})
art.on('document:mouseup', end)
scope.add(() => {
art.off('document:mouseup', end)
})
scope.add(cancel)
return cancel
}
@@ -0,0 +1,24 @@
export interface MiniGeometry {
width: number
height: number
viewportWidth: number
viewportHeight: number
}
export function clampMini(left: number, top: number, geometry: MiniGeometry): { left: number, top: number } {
const maxLeft = Math.max(0, geometry.viewportWidth - geometry.width)
const maxTop = Math.max(0, geometry.viewportHeight - geometry.height)
return { left: Math.max(0, Math.min(left, maxLeft)), top: Math.max(0, Math.min(top, maxTop)) }
}
export function miniPosition(left: unknown, top: unknown, geometry: MiniGeometry): { left: number, top: number, reset: boolean } {
if (typeof left === 'number' && Number.isFinite(left) && typeof top === 'number' && Number.isFinite(top)
&& left >= 0 && top >= 0 && left + geometry.width <= geometry.viewportWidth && top + geometry.height <= geometry.viewportHeight) { return { left, top, reset: false } }
return { ...clampMini(geometry.viewportWidth - geometry.width - 50, geometry.viewportHeight - geometry.height - 50, geometry), reset: true }
}
export function miniGeometry(element: HTMLElement): MiniGeometry {
const { width, height } = element.getBoundingClientRect()
const view = element.ownerDocument.defaultView!
return { width, height, viewportWidth: view.innerWidth, viewportHeight: view.innerHeight }
}
@@ -0,0 +1,79 @@
import type ResourceScope from '../lifecycle/scope'
import type { MiniHost } from './types'
import { listen } from '../lifecycle/resources'
import { silencePromise } from '../utils/error'
import { miniDrag } from './mini-drag'
export interface MiniView {
element: HTMLElement
cancelDrag: () => void
fresh: boolean
}
export function createMiniView(art: MiniHost, parent: ResourceScope, active: () => boolean, hide: () => void): MiniView | undefined {
const scope = parent.child()
if (scope.closed)
return
const existing = art.template.$mini
if (existing) {
const display = existing.style.display
scope.add(() => {
existing.style.display = display
})
return { element: existing, cancelDrag: () => {}, fresh: false }
}
const document = art.template.$player.ownerDocument
const element = document.createElement('div')
element.className = 'art-mini-popup'
scope.add(() => {
element.remove()
})
try {
const close = document.createElement('div')
close.className = 'art-mini-close'
close.append(art.icons.close)
if (scope.closed)
return
const state = document.createElement('div')
state.className = 'art-mini-state'
const play = art.icons.play
const pause = art.icons.pause
if (scope.closed)
return
state.append(play, pause)
element.append(close, state)
listen(scope, close, 'click', hide)
listen(scope, play, 'click', () => {
silencePromise(art.play())
})
listen(scope, pause, 'click', () => {
art.pause()
})
const update = () => {
if (scope.closed)
return
play.style.display = art.playing ? 'none' : 'flex'
pause.style.display = art.playing ? 'flex' : 'none'
}
update()
for (const name of ['video:playing', 'video:pause', 'video:timeupdate'] as const) {
art.on(name, update)
scope.add(() => {
art.off(name, update)
})
}
const cancelDrag = miniDrag(art, element, scope, active)
if (scope.closed)
return
art.template.$mini = element
document.body.append(element)
if (!scope.closed)
return { element, cancelDrag, fresh: true }
}
catch (error) {
scope.dispose()
if (art.template.$mini === element)
delete art.template.$mini
throw error
}
}
+142
View File
@@ -0,0 +1,142 @@
import type { MiniView } from './mini-view'
import type { Placement } from './placement'
import type { MiniHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { miniGeometry, miniPosition } from './mini-layout'
import { createMiniView } from './mini-view'
import { capturePlacement, restorePlacement } from './placement'
export function mini(art: MiniHost): PropertyDescriptor {
const { $player, $video } = art.template
const scope = getScope(art).child()
let view: MiniView | undefined
let placement: Placement | undefined
let revision = 0
let creating = false
let queued = false
const active = () => !isClosing(art) && $player.classList.contains('art-mini')
function restore() {
if (placement) {
const saved = placement
const current = revision
placement = undefined
try {
restorePlacement(saved)
}
catch (error) {
if (current === revision && !placement)
placement = saved
throw error
}
}
}
function hide() {
if (isClosing(art))
return
const current = ++revision
queued = false
restore()
if (isClosing(art) || current !== revision)
return
view?.cancelDrag()
$player.classList.remove('art-mini')
if (art.template.$mini) {
art.template.$mini.style.display = 'none'
art.emit('mini', false)
}
}
scope.add(() => {
revision++
try {
restore()
}
finally {
view?.cancelDrag()
$player.classList.remove('art-mini')
}
})
function show(): void {
if (isClosing(art))
return
const current = ++revision
if (creating) {
queued = true
return
}
art.state = 'mini'
if (isClosing(art) || current !== revision)
return
placement ??= capturePlacement($video)
$player.classList.add('art-mini')
try {
if (!view) {
creating = true
try {
view = createMiniView(art, scope, active, hide)
}
finally {
creating = false
}
}
if (queued) {
queued = false
show()
return
}
if (!view || isClosing(art))
return
if (current !== revision) {
if (!active())
view.element.style.display = 'none'
return
}
if (view.fresh)
view.element.prepend($video)
else
view.element.append($video)
if (isClosing(art) || current !== revision)
return
view.element.style.display = view.fresh ? '' : 'flex'
view.fresh = false
const top = art.storage.get('top')
const left = art.storage.get('left')
const position = miniPosition(left, top, miniGeometry(view.element))
if (isClosing(art) || current !== revision)
return
view.element.style.left = `${position.left}px`
view.element.style.top = `${position.top}px`
if (position.reset) {
art.storage.set('top', position.top)
if (isClosing(art) || current !== revision)
return
art.storage.set('left', position.left)
}
if (!isClosing(art) && current === revision)
art.emit('mini', true)
}
catch (error) {
const pendingReentry = queued
queued = false
if (!isClosing(art) && (current === revision || pendingReentry)) {
restore()
$player.classList.remove('art-mini')
if (view)
view.element.style.display = 'none'
}
throw error
}
}
return {
get: active,
set(value: boolean): void {
if (value)
show()
else
hide()
},
}
}
@@ -0,0 +1,106 @@
import type { FullscreenHost, NativeFullscreenAdapter } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { hasAbandonedFullscreen } from './fullscreen-abandoned'
import { requestFullscreen } from './fullscreen-request'
export function nativeFullscreen(art: FullscreenHost, adapter: NativeFullscreenAdapter): PropertyDescriptor {
const scope = getScope(art).child()
const { $player, $video } = art.template
const ownsFullscreen = () => adapter.element === $player || adapter.element === $video
let last = ownsFullscreen()
let revision = 0
let desired = last
let pending: ReturnType<typeof requestFullscreen> | undefined
let pendingEntry = false
let cancelledEntry = false
let requesting = false
function exitOwned() {
if (!ownsFullscreen())
return
try {
void Promise.resolve(adapter.exit()).catch(() => {})
}
catch {}
}
scope.add(() => {
revision += 1
desired = false
$player.classList.remove('art-fullscreen')
exitOwned()
})
listen(scope, adapter.document, adapter.changeEvent, () => {
const value = ownsFullscreen()
if (value && hasAbandonedFullscreen(adapter))
return
if (value && cancelledEntry) {
exitOwned()
return
}
if (value === last || isClosing(art))
return
last = value
const current = revision
const stale = () => isClosing(art) || ownsFullscreen() !== value || (current !== revision && desired !== value)
art.emit('fullscreen', value)
if (stale())
return
if (value)
art.state = 'fullscreen'
if (stale())
return
$player.classList.toggle('art-fullscreen', value)
art.emit('resize')
})
listen(scope, adapter.document, adapter.errorEvent, (event) => {
const ownEvent = event.target === $player || event.target === $video
|| (event.target === adapter.document && (requesting || pending !== undefined || ownsFullscreen()))
if (ownEvent && !isClosing(art))
art.emit('fullscreenError', event)
})
return {
get: () => !isClosing(art) && ownsFullscreen(),
set(value: boolean): Promise<void> {
if (isClosing(art))
return Promise.resolve()
const current = ++revision
desired = Boolean(value)
if (desired)
cancelledEntry = false
else if (pendingEntry)
cancelledEntry = true
pending?.cancel()
pending = undefined
pendingEntry = false
if (!desired && !ownsFullscreen())
return Promise.resolve()
requesting = true
pendingEntry = desired
const operation = requestFullscreen(adapter, scope, desired, () => {
if (isClosing(art) || !desired)
exitOwned()
}, () => !ownsFullscreen())
requesting = false
if (revision === current)
pending = operation
else
operation.cancel()
const result = operation.promise.catch((error) => {
if (!isClosing(art) && revision === current)
art.notice.show = error
throw error
}).finally(() => {
if (revision === current) {
pending = undefined
pendingEntry = false
}
})
// Assignment cannot consume a setter return, but descriptor callers still can.
void result.catch(() => {})
return result
},
}
}
@@ -0,0 +1,98 @@
import type { PipHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
export function nativePip(art: PipHost): PropertyDescriptor {
const { $video } = art.template
const document = $video.ownerDocument
const scope = getScope(art).child()
const owns = () => document.pictureInPictureElement === $video
let last = owns()
let desired = last
let pending = false
let cancelled = false
let revision = 0
$video.disablePictureInPicture = false
function exitOwned() {
if (!owns())
return
try {
void document.exitPictureInPicture().catch(() => {})
}
catch {}
}
function observe(result: Promise<unknown>, entering: boolean, current: number) {
void result.then(() => {
if (entering && (isClosing(art) || !desired))
exitOwned()
}, (error) => {
if (!isClosing(art) && revision === current)
art.notice.show = error
}).finally(() => {
if (revision === current)
pending = false
}).catch(() => {})
}
scope.add(() => {
revision++
desired = false
exitOwned()
})
listen(scope, $video, 'enterpictureinpicture', () => {
if (cancelled) {
exitOwned()
return
}
if (!owns() || last)
return
const current = revision
art.state = 'pip'
if (!isClosing(art) && current === revision && owns()) {
last = true
art.emit('pip', true)
}
})
listen(scope, $video, 'leavepictureinpicture', () => {
if (!last || owns())
return
last = false
art.emit('pip', false)
})
return {
// Native callers historically receive the media element, not a boolean.
get: () => !isClosing(art) && owns() ? $video : null,
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
desired = Boolean(value)
if (desired)
cancelled = false
else if (pending)
cancelled = true
pending = false
if (desired) {
art.state = 'pip'
if (isClosing(art) || revision !== current)
return
pending = true
try {
// Native invocation stays on the user gesture stack; the setter stays void.
observe($video.requestPictureInPicture!(), true, current)
}
catch (error) {
if (revision === current)
pending = false
throw error
}
}
else if (owns()) {
observe(document.exitPictureInPicture(), false, current)
}
},
}
}
@@ -0,0 +1,87 @@
import type { OrientationHost, OrientationLock } from './orientation-types'
import { getScope, isClosing } from '../lifecycle/instance'
interface Request {
orientation: OrientationLock
cancelled: boolean
settled: boolean
}
// Per-screen ownership prevents an old instance from unlocking a newer request.
const owners = new WeakMap<OrientationLock, Request>()
const className = 'art-auto-orientation-fullscreen'
function unlock(request: Request): void {
if (owners.get(request.orientation) !== request)
return
try {
request.orientation.unlock()
}
catch {}
}
export function nativeOrientation(art: OrientationHost, needRotate: () => boolean): (state: boolean) => void {
const { $player } = art.template
let current: Request | undefined
function cancel(): undefined {
const request = current
current = undefined
$player.classList.remove(className)
if (request) {
request.cancelled = true
unlock(request)
if (request.settled && owners.get(request.orientation) === request)
owners.delete(request.orientation)
}
}
getScope(art).add(cancel)
return (state) => {
if (isClosing(art))
return
if (!state) {
cancel()
return
}
if (current && !current.cancelled && owners.get(current.orientation) === current)
return
const orientation = $player.ownerDocument.defaultView?.screen?.orientation as OrientationLock | undefined
if (typeof orientation?.lock !== 'function' || typeof orientation.unlock !== 'function' || !needRotate())
return
const request: Request = { orientation, cancelled: false, settled: false }
current = request
owners.set(orientation, request)
const active = () => !isClosing(art) && !request.cancelled && current === request && owners.get(orientation) === request
const failed = (error: unknown) => {
request.settled = true
const show = active()
if (owners.get(orientation) === request)
owners.delete(orientation)
if (current === request)
current = undefined
if (show) {
$player.classList.remove(className)
art.notice.show = error
}
}
try {
// Invoke before yielding; fullscreen entry already supplied the gesture.
const result = orientation.lock(orientation.type.startsWith('portrait') ? 'landscape' : 'portrait')
Promise.resolve(result).then(() => {
request.settled = true
if (active()) {
$player.classList.add(className)
}
else {
unlock(request)
if (owners.get(orientation) === request)
owners.delete(orientation)
}
}).catch(failed)
}
catch (error) {
failed(error)
}
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface OrientationHost extends SubscriptionHost<{ fullscreenWeb: [boolean], fullscreen: [boolean] }> {
constructor: { AUTO_ORIENTATION_TIME?: number }
template: { $player: HTMLElement, $video: { readonly videoWidth: number, readonly videoHeight: number } }
fullscreenWeb: boolean
isRotate: boolean
notice: { set show(value: unknown) }
emit: (name: 'resize') => unknown
}
export interface OrientationLock {
readonly type: string
lock: (mode: 'portrait' | 'landscape') => Promise<void>
unlock: () => void
}
@@ -0,0 +1,72 @@
import type { OrientationHost } from './orientation-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { getSafeAreaInsets } from '../utils'
const className = 'art-auto-orientation'
const properties = ['width', 'height', 'transform-origin', 'transform'] as const
export function webOrientation(art: OrientationHost, needRotate: () => boolean): (state: boolean) => void {
const { $player } = art.template
const scope = getScope(art)
let cancel = () => {}
let saved: { name: string, value: string, priority: string }[] | undefined
function clear(): undefined {
cancel()
cancel = () => {}
const previous = saved
saved = undefined
// fullscreenWeb restores its complete entry style before emitting false.
if (previous && art.fullscreenWeb) {
for (const { name, value, priority } of previous)
$player.style.setProperty(name, value, priority)
}
const wasRotated = $player.classList.contains(className)
$player.classList.remove(className)
if (wasRotated) {
art.isRotate = false
if (!isClosing(art))
art.emit('resize')
}
}
scope.add(clear)
function apply(emit: boolean): void {
if (!art.fullscreenWeb || !needRotate())
return
const insets = getSafeAreaInsets()
if (isClosing(art) || !art.fullscreenWeb)
return
const viewport = $player.ownerDocument.documentElement
saved ??= properties.map(name => ({ name, value: $player.style.getPropertyValue(name), priority: $player.style.getPropertyPriority(name) }))
$player.style.width = `${viewport.clientHeight - insets.top - insets.bottom}px`
$player.style.height = `${viewport.clientWidth - insets.left - insets.right}px`
$player.style.transformOrigin = '0 0'
$player.style.transform = `rotate(90deg) translate(${insets.top}px, -${viewport.clientWidth - insets.right}px)`
$player.classList.add(className)
art.isRotate = true
if (emit)
art.emit('resize')
}
return (state) => {
if (isClosing(art))
return
if (!state) {
clear()
return
}
if (saved) {
apply(false)
return
}
if (!needRotate())
return
cancel()
cancel = timeout(scope, () => {
if (!saved)
apply(true)
}, Number(art.constructor.AUTO_ORIENTATION_TIME ?? 0))
}
}
@@ -0,0 +1,19 @@
export interface Placement {
node: Node
parent: ParentNode | null
next: ChildNode | null
}
export function capturePlacement(node: Node): Placement {
return { node, parent: node.parentNode, next: node.nextSibling }
}
export function restorePlacement({ node, parent, next }: Placement): void {
if (!parent) {
node.parentNode?.removeChild(node)
return
}
const anchor = next?.parentNode === parent ? next : null
if (node.parentNode !== parent || node.nextSibling !== anchor)
parent.insertBefore(node, anchor)
}
@@ -0,0 +1,29 @@
import type { PlaybackNotice } from '../media/playback'
import type { Size } from './sizing'
interface MediaDimensions {
readonly videoWidth: number
readonly videoHeight: number
}
export interface AutoSizeHost {
template: { $container: HTMLElement, $player: HTMLElement, $video: MediaDimensions }
readonly width: number
readonly height: number
emit: (name: 'autoSize', size: Size) => unknown
}
export interface AutoHeightHost {
template: { $container: HTMLElement, $video: MediaDimensions }
emit: (name: 'autoHeight', height: number) => unknown
}
export interface AspectRatioHost extends PlaybackNotice {
template: { $player: HTMLElement, $video: HTMLElement }
emit: (name: 'aspectRatio', ratio: string) => unknown
}
export interface FlipHost extends PlaybackNotice {
template: { $player: HTMLElement }
emit: (name: 'flip', flip: string) => unknown
}
+24
View File
@@ -0,0 +1,24 @@
export interface Size {
width: number
height: number
}
export function positiveSize({ width, height }: Size): boolean {
return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0
}
export function containSize(container: Size, ratio: number): (Size & { axis: 'width' | 'height' }) | undefined {
if (!positiveSize(container) || !Number.isFinite(ratio) || ratio <= 0)
return
const result = container.width / container.height > ratio
? { width: ratio * container.height, height: container.height, axis: 'width' as const }
: { width: container.width, height: container.width / ratio, axis: 'height' as const }
return positiveSize(result) ? result : undefined
}
export function proportionalHeight(width: number, media: Size): number | undefined {
if (!Number.isFinite(width) || width <= 0 || !positiveSize(media))
return
const height = media.height * (width / media.width)
return Number.isFinite(height) && height > 0 ? height : undefined
}
+71
View File
@@ -0,0 +1,71 @@
import type { SubscriptionHost } from '../component/resources'
import type { OptionalMediaCapabilities } from '../media/types'
export interface WebFullscreenHost {
constructor: { FULLSCREEN_WEB_IN_BODY: boolean }
template: { $container: HTMLElement, $player: HTMLElement }
state: string
emit: (name: 'fullscreenWeb' | 'resize', ...args: unknown[]) => unknown
}
export interface WebFullscreenProperty {
fullscreenWeb: boolean
}
export interface FullscreenHost {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities }
state: string
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
emit: (name: 'fullscreen' | 'fullscreenError' | 'resize', ...args: unknown[]) => unknown
once: (name: 'video:loadedmetadata', listener: () => void) => unknown
on: (name: 'document:webkitfullscreenchange', listener: () => void) => unknown
off: (name: 'video:loadedmetadata' | 'document:webkitfullscreenchange', listener: () => void) => unknown
}
export interface NativeFullscreenAdapter {
document: Document
target: HTMLElement
readonly element: Element | null
elementProperty: string
changeEvent: string
errorEvent: string
request: () => unknown
exit: () => unknown
}
export interface PipHost {
template: { $video: HTMLElement & OptionalMediaCapabilities & { disablePictureInPicture?: boolean } }
state: string
notice: { set show(value: unknown) }
i18n: { get: (key: string) => string }
emit: (name: 'pip', value: boolean) => unknown
}
export interface PipProperty {
get pip(): HTMLElement | null | boolean
set pip(value: boolean)
}
export interface MiniEvents {
'video:playing': []
'video:pause': []
'video:timeupdate': []
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
}
export interface MiniHost extends SubscriptionHost<MiniEvents> {
template: { $player: HTMLElement, $video: HTMLElement, $mini?: HTMLElement }
icons: { close: HTMLElement, play: HTMLElement, pause: HTMLElement }
storage: { get: (key: string) => unknown, set: (key: string, value: number) => unknown }
state: string
playing: boolean
play: () => unknown
pause: () => unknown
emit: (name: 'mini', value: boolean) => unknown
}
export interface MiniProperty {
mini: boolean
}
@@ -0,0 +1,40 @@
import type { OptionalMediaCapabilities } from '../media/types'
type Video = HTMLElement & OptionalMediaCapabilities
const abandoned = new WeakSet<Video>()
export function videoIsFullscreen(video: Video, observed = false): boolean {
if (typeof video.webkitPresentationMode === 'string')
return video.webkitPresentationMode === 'fullscreen'
if (typeof video.webkitDisplayingFullscreen === 'boolean')
return video.webkitDisplayingFullscreen
return video.ownerDocument.fullscreenElement === video || observed
}
export function clearAbandonedVideo(video: Video): void {
abandoned.delete(video)
video.removeEventListener('webkitbeginfullscreen', releaseAbandonedVideo)
video.removeEventListener('webkitpresentationmodechanged', releaseAbandonedVideo)
}
function releaseAbandonedVideo(this: Video, event: Event): void {
if (!abandoned.has(this) || !videoIsFullscreen(this, event.type === 'webkitbeginfullscreen'))
return
clearAbandonedVideo(this)
try {
this.webkitExitFullscreen?.()
}
catch {}
}
export function abandonVideo(video: Video): void {
abandoned.add(video)
// The listener is held by the video itself, with no instance or scope closure.
video.addEventListener('webkitbeginfullscreen', releaseAbandonedVideo)
video.addEventListener('webkitpresentationmodechanged', releaseAbandonedVideo)
}
export function isAbandonedVideo(video: Video): boolean {
return abandoned.has(video)
}
@@ -0,0 +1,87 @@
import type { FullscreenHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
import { abandonVideo, clearAbandonedVideo, isAbandonedVideo, videoIsFullscreen } from './video-fullscreen-state'
export function videoFullscreen(art: FullscreenHost): PropertyDescriptor {
const { $video } = art.template
const scope = getScope(art).child()
let observed = false
let last = videoIsFullscreen($video)
let desired = last
let pending = false
let revision = 0
const active = () => videoIsFullscreen($video, observed)
function changed(hint?: boolean) {
if (isClosing(art) || isAbandonedVideo($video))
return
if (typeof hint === 'boolean')
observed = hint
const value = active()
if (value)
pending = false
if (value === last)
return
last = value
const current = revision
const stale = () => isClosing(art) || active() !== value || (current !== revision && desired !== value)
art.emit('fullscreen', value)
if (stale())
return
if (value)
art.state = 'fullscreen'
if (!stale())
art.emit('resize')
}
scope.add(() => {
revision++
desired = false
if (pending)
abandonVideo($video)
if (active())
$video.webkitExitFullscreen?.()
})
listen(scope, $video, 'webkitbeginfullscreen', () => changed(true))
listen(scope, $video, 'webkitendfullscreen', () => changed(false))
listen(scope, $video, 'webkitpresentationmodechanged', () => changed())
const documentChanged = () => changed()
art.on('document:webkitfullscreenchange', documentChanged)
scope.add(() => {
art.off('document:webkitfullscreenchange', documentChanged)
})
return {
get: () => !isClosing(art) && active(),
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
desired = Boolean(value)
if (value) {
clearAbandonedVideo($video)
art.state = 'fullscreen'
if (isClosing(art) || current !== revision)
return
pending = true
try {
$video.webkitEnterFullscreen!()
}
catch (error) {
if (current === revision)
pending = false
throw error
}
}
else {
if (pending) {
pending = false
abandonVideo($video)
}
if (active())
$video.webkitExitFullscreen!()
}
},
}
}
@@ -0,0 +1,99 @@
import type { Placement } from './placement'
import type { WebFullscreenHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { capturePlacement, restorePlacement } from './placement'
interface Snapshot {
placement: Placement
style: string | null
}
export function webFullscreen(art: WebFullscreenHost): (value: boolean) => void {
const { $player } = art.template
let saved: Snapshot | undefined
let revision = 0
function restore(snapshot: Snapshot, active: () => boolean): boolean {
if (snapshot.style === null)
$player.removeAttribute('style')
else
$player.setAttribute('style', snapshot.style)
if (!active())
return false
$player.classList.remove('art-fullscreen-web')
if (!active())
return false
restorePlacement(snapshot.placement)
return active()
}
getScope(art).add(() => {
revision += 1
const snapshot = saved
saved = undefined
if (snapshot) {
try {
restore(snapshot, () => true)
}
catch (error) {
// Destruction cannot retry: detach only if restoration left our node outside its owner.
try {
if ($player.parentNode !== snapshot.placement.parent)
$player.parentNode?.removeChild($player)
}
catch (detachError) {
throw new ResourceCleanupError([error, detachError])
}
throw error
}
}
})
return (value) => {
if (isClosing(art))
return
const current = ++revision
const active = () => current === revision && !isClosing(art)
if (value) {
if (!saved)
saved = { placement: capturePlacement($player), style: $player.getAttribute('style') }
const snapshot = saved
try {
if (art.constructor.FULLSCREEN_WEB_IN_BODY && $player.parentNode !== document.body)
document.body.appendChild($player)
if (!active())
return
art.state = 'fullscreenWeb'
if (!active())
return
$player.style.width = '100%'
$player.style.height = '100%'
$player.classList.add('art-fullscreen-web')
}
catch (error) {
if (active() && restore(snapshot, active))
saved = undefined
throw error
}
if (!active())
return
art.emit('fullscreenWeb', true)
}
else {
if (saved) {
if (!restore(saved, active))
return
saved = undefined
}
else {
$player.classList.remove('art-fullscreen-web')
}
if (!active())
return
art.emit('fullscreenWeb', false)
}
if (active())
art.emit('resize')
}
}
@@ -0,0 +1,107 @@
import type { PipHost } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { listen } from '../lifecycle/resources'
type Video = PipHost['template']['$video']
const abandoned = new WeakSet<Video>()
const active = (video: Video) => video.webkitPresentationMode === 'picture-in-picture'
function clearAbandoned(video: Video) {
abandoned.delete(video)
video.removeEventListener('webkitpresentationmodechanged', releaseAbandoned)
video.removeEventListener('enterpictureinpicture', releaseAbandoned)
}
function releaseAbandoned(this: Video) {
if (!abandoned.has(this) || !active(this))
return
clearAbandoned(this)
try {
this.webkitSetPresentationMode?.('inline')
}
catch {}
}
function abandon(video: Video) {
abandoned.add(video)
video.addEventListener('webkitpresentationmodechanged', releaseAbandoned)
video.addEventListener('enterpictureinpicture', releaseAbandoned)
}
export function webkitPip(art: PipHost): PropertyDescriptor {
const { $video } = art.template
const scope = getScope(art).child()
let last = false
let pending = false
let revision = 0
let emissions = 0
$video.webkitSetPresentationMode!('inline')
function changed(force = false) {
if (isClosing(art) || abandoned.has($video))
return
const value = active($video)
if (value)
pending = false
if (value === last && !force)
return
const current = revision
if (value)
art.state = 'pip'
if (isClosing(art) || revision !== current || active($video) !== value)
return
last = value
emissions++
art.emit('pip', value)
}
scope.add(() => {
revision++
if (pending)
abandon($video)
if (active($video))
$video.webkitSetPresentationMode!('inline')
})
for (const name of ['webkitpresentationmodechanged', 'enterpictureinpicture', 'leavepictureinpicture'])
listen(scope, $video, name, () => changed())
return {
get: () => !isClosing(art) && active($video),
set(value: boolean): void {
if (isClosing(art))
return
const current = ++revision
const before = emissions
if (value) {
if ($video.webkitSupportsPresentationMode?.('picture-in-picture') === false) {
art.notice.show = art.i18n.get('PIP Not Supported')
return
}
clearAbandoned($video)
art.state = 'pip'
if (isClosing(art) || revision !== current)
return
pending = true
try {
$video.webkitSetPresentationMode!('picture-in-picture')
}
catch (error) {
if (current === revision)
pending = false
throw error
}
}
else {
if (pending) {
pending = false
abandon($video)
}
if (active($video))
$video.webkitSetPresentationMode!('inline')
}
if (!isClosing(art) && current === revision && before === emissions && active($video) === Boolean(value))
changed(true)
},
}
}