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)
},
}
}
@@ -1,47 +0,0 @@
import { def, setStyle } from '../utils'
export default function aspectRatioMix(art) {
const {
i18n,
notice,
template: { $video, $player },
} = art
def(art, 'aspectRatio', {
get() {
return $player.dataset.aspectRatio || 'default'
},
set(ratio) {
if (!ratio)
ratio = 'default'
if (ratio === 'default') {
setStyle($video, 'width', null)
setStyle($video, 'height', null)
setStyle($video, 'margin', null)
delete $player.dataset.aspectRatio
}
else {
const ratioArray = ratio.split(':').map(Number)
const { clientWidth, clientHeight } = $player
const playerRatio = clientWidth / clientHeight
const setupRatio = ratioArray[0] / ratioArray[1]
if (playerRatio > setupRatio) {
setStyle($video, 'width', `${setupRatio * clientHeight}px`)
setStyle($video, 'height', '100%')
setStyle($video, 'margin', '0 auto')
}
else {
setStyle($video, 'width', '100%')
setStyle($video, 'height', `${clientWidth / setupRatio}px`)
setStyle($video, 'margin', 'auto 0')
}
$player.dataset.aspectRatio = ratio
}
notice.show = `${i18n.get('Aspect Ratio')}: ${ratio === 'default' ? i18n.get('Default') : ratio}`
art.emit('aspectRatio', ratio)
},
})
}
@@ -0,0 +1,44 @@
import type { AspectRatioHost } from '../display/sizing-types'
import { containSize } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function aspectRatioMix(art: AspectRatioHost): void {
const { i18n, notice, template: { $video, $player } } = art
def(art, 'aspectRatio', {
get: () => $player.dataset.aspectRatio || 'default',
set(ratio: string) {
if (isClosing(art))
return
if (!ratio)
ratio = 'default'
if (ratio === 'default') {
$video.style.width = ''
$video.style.height = ''
$video.style.margin = ''
delete $player.dataset.aspectRatio
}
else {
// Keep the legacy split/Number coercion and dataset even for invalid ratios.
const parts = ratio.split(':').map(Number)
const size = containSize({ width: $player.clientWidth, height: $player.clientHeight }, parts[0]! / parts[1]!)
if (isClosing(art))
return
if (size?.axis === 'width') {
$video.style.width = `${size.width}px`
$video.style.height = '100%'
$video.style.margin = '0 auto'
}
else if (size) {
$video.style.width = '100%'
$video.style.height = `${size.height}px`
$video.style.margin = 'auto 0'
}
$player.dataset.aspectRatio = ratio
}
notice.show = `${i18n.get('Aspect Ratio')}: ${ratio === 'default' ? i18n.get('Default') : ratio}`
if (!isClosing(art))
art.emit('aspectRatio', ratio)
},
})
}
@@ -1,17 +0,0 @@
import { def, setStyle } from '../utils'
export default function autoHeightMix(art) {
const {
template: { $container, $video },
} = art
def(art, 'autoHeight', {
value() {
const { clientWidth } = $container
const { videoHeight, videoWidth } = $video
const height = videoHeight * (clientWidth / videoWidth)
setStyle($container, 'height', `${height}px`)
art.emit('autoHeight', height)
},
})
}
@@ -0,0 +1,19 @@
import type { AutoHeightHost } from '../display/sizing-types'
import { proportionalHeight } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function autoHeightMix(art: AutoHeightHost): void {
const { $container, $video } = art.template
def(art, 'autoHeight', {
value() {
if (isClosing(art))
return
const height = proportionalHeight($container.clientWidth, { width: $video.videoWidth, height: $video.videoHeight })
if (height === undefined || isClosing(art))
return
$container.style.height = `${height}px`
art.emit('autoHeight', height)
},
})
}
@@ -1,28 +0,0 @@
import { def, getRect, setStyle } from '../utils'
export default function autoSizeMix(art) {
const { $container, $player, $video } = art.template
def(art, 'autoSize', {
value() {
const { videoWidth, videoHeight } = $video
const { width, height } = getRect($container)
const videoRatio = videoWidth / videoHeight
const containerRatio = width / height
if (containerRatio > videoRatio) {
const percentage = ((height * videoRatio) / width) * 100
setStyle($player, 'width', `${percentage}%`)
setStyle($player, 'height', '100%')
}
else {
const percentage = (width / videoRatio / height) * 100
setStyle($player, 'width', '100%')
setStyle($player, 'height', `${percentage}%`)
}
art.emit('autoSize', {
width: art.width,
height: art.height,
})
},
})
}
@@ -0,0 +1,30 @@
import type { AutoSizeHost } from '../display/sizing-types'
import { containSize, positiveSize } from '../display/sizing'
import { isClosing } from '../lifecycle/instance'
import { def, getRect } from '../utils'
export default function autoSizeMix(art: AutoSizeHost): void {
const { $container, $player, $video } = art.template
def(art, 'autoSize', {
value() {
if (isClosing(art))
return
const media = { width: $video.videoWidth, height: $video.videoHeight }
if (!positiveSize(media))
return
const container = getRect($container)
const size = containSize(container, media.width / media.height)
if (!size || isClosing(art))
return
if (size.axis === 'width') {
$player.style.width = `${(size.width / container.width) * 100}%`
$player.style.height = '100%'
}
else {
$player.style.width = '100%'
$player.style.height = `${(size.height / container.height) * 100}%`
}
art.emit('autoSize', { width: art.width, height: art.height })
},
})
}
-28
View File
@@ -1,28 +0,0 @@
import { capitalize, def } from '../utils'
export default function flipMix(art) {
const {
template: { $player },
i18n,
notice,
} = art
def(art, 'flip', {
get() {
return $player.dataset.flip || 'normal'
},
set(flip) {
if (!flip)
flip = 'normal'
if (flip === 'normal') {
delete $player.dataset.flip
}
else {
$player.dataset.flip = flip
}
notice.show = `${i18n.get('Video Flip')}: ${i18n.get(capitalize(flip))}`
art.emit('flip', flip)
},
})
}
+23
View File
@@ -0,0 +1,23 @@
import type { FlipHost } from '../display/sizing-types'
import { isClosing } from '../lifecycle/instance'
import { capitalize, def } from '../utils'
export default function flipMix(art: FlipHost): void {
const { template: { $player }, i18n, notice } = art
def(art, 'flip', {
get: () => $player.dataset.flip || 'normal',
set(flip: string) {
if (isClosing(art))
return
if (!flip)
flip = 'normal'
if (flip === 'normal')
delete $player.dataset.flip
else
$player.dataset.flip = flip
notice.show = `${i18n.get('Video Flip')}: ${i18n.get(capitalize(flip))}`
if (!isClosing(art))
art.emit('flip', flip)
},
})
}
@@ -1,88 +0,0 @@
import screenfull from '../libs/screenfull'
import { addClass, def, get, removeClass } from '../utils'
export default function fullscreenMix(art) {
const {
i18n,
notice,
template: { $video, $player },
} = art
const nativeScreenfull = (art) => {
screenfull.on('change', () => {
art.emit('fullscreen', screenfull.isFullscreen)
if (screenfull.isFullscreen) {
art.state = 'fullscreen'
addClass($player, 'art-fullscreen')
}
else {
removeClass($player, 'art-fullscreen')
}
art.emit('resize')
})
screenfull.on('error', (event) => {
art.emit('fullscreenError', event)
})
def(art, 'fullscreen', {
get() {
return screenfull.isFullscreen
},
async set(value) {
if (value) {
await screenfull.request($player)
}
else {
await screenfull.exit()
}
},
})
}
const webkitScreenfull = (art) => {
art.on('document:webkitfullscreenchange', () => {
art.emit('fullscreen', art.fullscreen)
art.emit('resize')
})
def(art, 'fullscreen', {
get() {
return document.fullscreenElement === $video
},
set(value) {
if (value) {
art.state = 'fullscreen'
$video.webkitEnterFullscreen()
}
else {
$video.webkitExitFullscreen()
}
},
})
}
art.once('video:loadedmetadata', () => {
if (screenfull.isEnabled) {
nativeScreenfull(art)
}
else if ($video.webkitSupportsFullscreen) {
webkitScreenfull(art)
}
else {
def(art, 'fullscreen', {
get() {
return false
},
set() {
notice.show = i18n.get('Fullscreen Not Supported')
},
})
}
// Asynchronous setting
def(art, 'fullscreen', get(art, 'fullscreen'))
})
}
@@ -0,0 +1,35 @@
import type { FullscreenHost } from '../display/types'
import { fullscreenAdapter } from '../display/fullscreen-adapter'
import { nativeFullscreen } from '../display/native-fullscreen'
import { videoFullscreen } from '../display/video-fullscreen'
import { getScope, isClosing } from '../lifecycle/instance'
import { def, get } from '../utils'
export default function fullscreenMix(art: FullscreenHost): void {
const { i18n, notice, template: { $video, $player } } = art
const metadata = () => {
if (isClosing(art))
return
const adapter = fullscreenAdapter($player)
if (adapter) {
def(art, 'fullscreen', nativeFullscreen(art, adapter))
}
else if ($video.webkitSupportsFullscreen && typeof $video.webkitEnterFullscreen === 'function' && typeof $video.webkitExitFullscreen === 'function') {
def(art, 'fullscreen', videoFullscreen(art))
}
else {
def(art, 'fullscreen', {
get: () => false,
set() {
if (!isClosing(art))
notice.show = i18n.get('Fullscreen Not Supported')
},
})
}
def(art, 'fullscreen', get(art, 'fullscreen')!)
}
art.once('video:loadedmetadata', metadata)
getScope(art).add(() => {
art.off('video:loadedmetadata', metadata)
})
}
@@ -1,41 +0,0 @@
import { addClass, append, def, hasClass, removeClass, setStyle } from '../utils'
export default function fullscreenWebMix(art) {
const {
constructor,
template: { $container, $player },
} = art
let cssText = ''
def(art, 'fullscreenWeb', {
get() {
return hasClass($player, 'art-fullscreen-web')
},
set(value) {
if (value) {
cssText = $player.style.cssText
if (constructor.FULLSCREEN_WEB_IN_BODY) {
append(document.body, $player)
}
art.state = 'fullscreenWeb'
setStyle($player, 'width', '100%')
setStyle($player, 'height', '100%')
addClass($player, 'art-fullscreen-web')
art.emit('fullscreenWeb', true)
}
else {
if (constructor.FULLSCREEN_WEB_IN_BODY) {
append($container, $player)
}
if (cssText) {
$player.style.cssText = cssText
cssText = ''
}
removeClass($player, 'art-fullscreen-web')
art.emit('fullscreenWeb', false)
}
art.emit('resize')
},
})
}
@@ -0,0 +1,13 @@
import type { WebFullscreenHost, WebFullscreenProperty } from '../display/types'
import { webFullscreen } from '../display/web-fullscreen'
import { def } from '../utils'
export default function fullscreenWebMix(art: WebFullscreenHost): asserts art is WebFullscreenHost & WebFullscreenProperty {
const setFullscreen = webFullscreen(art)
def(art, 'fullscreenWeb', {
get(): boolean {
return art.template.$player.classList.contains('art-fullscreen-web')
},
set: setFullscreen,
})
}
-134
View File
@@ -1,134 +0,0 @@
import { addClass, append, createElement, def, getRect, hasClass, isInViewport, removeClass, setStyle, silencePromise } from '../utils'
export default function miniMix(art) {
const {
icons,
proxy,
storage,
template: { $player, $video },
} = art
let isDragging = false
let lastPageX = 0
let lastPageY = 0
function hideMini() {
const { $mini } = art.template
if ($mini) {
removeClass($player, 'art-mini')
setStyle($mini, 'display', 'none')
$player.prepend($video)
art.emit('mini', false)
}
}
function initState($play, $pause) {
if (art.playing) {
setStyle($play, 'display', `none`)
setStyle($pause, 'display', `flex`)
}
else {
setStyle($play, 'display', `flex`)
setStyle($pause, 'display', `none`)
}
}
function createMini() {
const { $mini } = art.template
if ($mini) {
append($mini, $video)
return setStyle($mini, 'display', 'flex')
}
else {
const $mini = createElement('div')
addClass($mini, 'art-mini-popup')
append(document.body, $mini)
art.template.$mini = $mini
append($mini, $video)
const $close = append($mini, `<div class="art-mini-close"></div>`)
append($close, icons.close)
proxy($close, 'click', hideMini)
const $state = append($mini, `<div class="art-mini-state"></div>`)
const $play = append($state, icons.play)
const $pause = append($state, icons.pause)
proxy($play, 'click', () => silencePromise(art.play()))
proxy($pause, 'click', () => art.pause())
initState($play, $pause)
art.on('video:playing', () => initState($play, $pause))
art.on('video:pause', () => initState($play, $pause))
art.on('video:timeupdate', () => initState($play, $pause))
proxy($mini, 'mousedown', (event) => {
isDragging = event.button === 0
lastPageX = event.pageX
lastPageY = event.pageY
})
art.on('document:mousemove', (event) => {
if (isDragging) {
addClass($mini, 'art-mini-dragging')
const x = event.pageX - lastPageX
const y = event.pageY - lastPageY
setStyle($mini, 'transform', `translate(${x}px, ${y}px)`)
}
})
art.on('document:mouseup', () => {
if (isDragging) {
isDragging = false
removeClass($mini, 'art-mini-dragging')
const rect = getRect($mini)
storage.set('left', rect.left)
storage.set('top', rect.top)
setStyle($mini, 'left', `${rect.left}px`)
setStyle($mini, 'top', `${rect.top}px`)
setStyle($mini, 'transform', null)
}
})
return $mini
}
}
function initMini() {
const { $mini } = art.template
const rect = getRect($mini)
const top = window.innerHeight - rect.height - 50
const left = window.innerWidth - rect.width - 50
storage.set('top', top)
storage.set('left', left)
setStyle($mini, 'top', `${top}px`)
setStyle($mini, 'left', `${left}px`)
}
def(art, 'mini', {
get() {
return hasClass($player, 'art-mini')
},
set(value) {
if (value) {
art.state = 'mini'
addClass($player, 'art-mini')
const $mini = createMini()
const top = storage.get('top')
const left = storage.get('left')
if (typeof top === 'number' && typeof left === 'number') {
setStyle($mini, 'top', `${top}px`)
setStyle($mini, 'left', `${left}px`)
if (!isInViewport($mini)) {
initMini()
}
}
else {
initMini()
}
art.emit('mini', true)
}
else {
hideMini()
}
},
})
}
+7
View File
@@ -0,0 +1,7 @@
import type { MiniHost, MiniProperty } from '../display/types'
import { mini } from '../display/mini'
import { def } from '../utils'
export default function miniMix(art: MiniHost): asserts art is MiniHost & MiniProperty {
def(art, 'mini', mini(art))
}
-87
View File
@@ -1,87 +0,0 @@
import { def } from '../utils'
function nativePip(art) {
const {
template: { $video },
proxy,
notice,
} = art
$video.disablePictureInPicture = false
def(art, 'pip', {
get() {
return document.pictureInPictureElement
},
set(value) {
if (value) {
art.state = 'pip'
$video.requestPictureInPicture().catch((err) => {
notice.show = err
throw err
})
}
else {
document.exitPictureInPicture().catch((err) => {
notice.show = err
throw err
})
}
},
})
proxy($video, 'enterpictureinpicture', () => {
art.emit('pip', true)
})
proxy($video, 'leavepictureinpicture', () => {
art.emit('pip', false)
})
}
function webkitPip(art) {
const { $video } = art.template
$video.webkitSetPresentationMode('inline')
def(art, 'pip', {
get() {
return $video.webkitPresentationMode === 'picture-in-picture'
},
set(value) {
if (value) {
art.state = 'pip'
$video.webkitSetPresentationMode('picture-in-picture')
art.emit('pip', true)
}
else {
$video.webkitSetPresentationMode('inline')
art.emit('pip', false)
}
},
})
}
export default function pipMix(art) {
const {
i18n,
notice,
template: { $video },
} = art
if (document.pictureInPictureEnabled) {
nativePip(art)
}
else if ($video.webkitSupportsPresentationMode) {
webkitPip(art)
}
else {
def(art, 'pip', {
get() {
return false
},
set() {
notice.show = i18n.get('PIP Not Supported')
},
})
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { PipHost, PipProperty } from '../display/types'
import { nativePip } from '../display/native-pip'
import { webkitPip } from '../display/webkit-pip'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export default function pipMix(art: PipHost): asserts art is PipHost & PipProperty {
const { i18n, notice, template: { $video } } = art
const document = $video.ownerDocument
if (document.pictureInPictureEnabled && typeof $video.requestPictureInPicture === 'function' && typeof document.exitPictureInPicture === 'function') {
def(art, 'pip', nativePip(art))
}
else if (typeof $video.webkitSupportsPresentationMode === 'function' && typeof $video.webkitSetPresentationMode === 'function') {
def(art, 'pip', webkitPip(art))
}
else {
def(art, 'pip', {
get: () => false,
set() {
if (!isClosing(art))
notice.show = i18n.get('PIP Not Supported')
},
})
}
}
@@ -1,101 +0,0 @@
import { addClass, getSafeAreaInsets, hasClass, removeClass, setStyle } from '../utils'
export default function autoOrientation(art) {
const {
notice,
constructor,
template: { $player, $video },
} = art
const WEB_CLASS = 'art-auto-orientation'
const FS_CLASS = 'art-auto-orientation-fullscreen'
let fsLocked = false
function applyWebRotate() {
const insets = getSafeAreaInsets()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
const safeWidth = viewHeight - insets.top - insets.bottom
const safeHeight = viewWidth - insets.left - insets.right
setStyle($player, 'width', `${safeWidth}px`)
setStyle($player, 'height', `${safeHeight}px`)
setStyle($player, 'transform-origin', '0 0')
setStyle($player, 'transform', `rotate(90deg) translate(${insets.top}px, -${viewWidth - insets.right}px)`)
addClass($player, WEB_CLASS)
art.isRotate = true
art.emit('resize')
}
function clearWebRotate() {
setStyle($player, 'width', '')
setStyle($player, 'height', '')
setStyle($player, 'transform-origin', '')
setStyle($player, 'transform', '')
removeClass($player, WEB_CLASS)
art.isRotate = false
art.emit('resize')
}
function needRotate() {
const { videoWidth, videoHeight } = $video
const vw = document.documentElement.clientWidth
const vh = document.documentElement.clientHeight
return (videoWidth > videoHeight && vw < vh) || (videoWidth < videoHeight && vw > vh)
}
art.on('fullscreenWeb', (state) => {
if (state) {
if (needRotate()) {
const delay = Number(constructor.AUTO_ORIENTATION_TIME ?? 0)
setTimeout(() => {
if (art.fullscreenWeb && !hasClass($player, WEB_CLASS)) {
applyWebRotate()
}
}, delay)
}
}
else {
if (hasClass($player, WEB_CLASS))
clearWebRotate()
}
})
art.on('fullscreen', async (state) => {
const canLock = !!screen?.orientation?.lock
if (state) {
if (canLock && needRotate()) {
try {
const last = screen.orientation.type
const opposite = last.startsWith('portrait') ? 'landscape' : 'portrait'
await screen.orientation.lock(opposite)
fsLocked = true
addClass($player, FS_CLASS)
}
catch (err) {
fsLocked = false
notice.show = err
}
}
}
else {
if (hasClass($player, FS_CLASS)) {
removeClass($player, FS_CLASS)
}
if (canLock && fsLocked) {
try {
screen.orientation.unlock()
}
catch { }
fsLocked = false
}
}
})
return {
name: 'autoOrientation',
get state() {
return hasClass($player, WEB_CLASS)
},
}
}
@@ -0,0 +1,30 @@
import type { OrientationHost } from '../display/orientation-types'
import { nativeOrientation } from '../display/orientation-native'
import { webOrientation } from '../display/orientation-web'
import { positiveSize } from '../display/sizing'
import { getScope } from '../lifecycle/instance'
export default function autoOrientation(art: OrientationHost): { name: string, readonly state: boolean } {
const { $player, $video } = art.template
const needRotate = () => {
const media = { width: $video.videoWidth, height: $video.videoHeight }
const viewport = $player.ownerDocument.documentElement
const view = { width: viewport.clientWidth, height: viewport.clientHeight }
return positiveSize(media) && positiveSize(view)
&& ((media.width > media.height && view.width < view.height) || (media.width < media.height && view.width > view.height))
}
const web = webOrientation(art, needRotate)
const native = nativeOrientation(art, needRotate)
art.on('fullscreenWeb', web)
art.on('fullscreen', native)
getScope(art).add(() => {
art.off('fullscreenWeb', web)
art.off('fullscreen', native)
})
return {
name: 'autoOrientation',
get state() {
return $player.classList.contains('art-auto-orientation')
},
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
import type { BuiltinRegistry, PluginHost } from './types'
import type { BuiltinRegistry, PluginFactory, PluginHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import autoOrientation from './autoOrientation'
@@ -15,8 +15,10 @@ export default function installBuiltins<Host extends PluginHost<Host>>(registry:
registry.add(lock)
if (!isClosing(registry.art) && option.autoPlayback && !option.isLive)
registry.add(autoPlayback)
if (!isClosing(registry.art) && option.autoOrientation && isMobile)
registry.add(autoOrientation)
if (!isClosing(registry.art) && option.autoOrientation && isMobile) {
// The JS constructor supplies the complete host before builtin installation.
registry.add(autoOrientation as unknown as PluginFactory<Host>)
}
if (!isClosing(registry.art) && option.fastForward && isMobile && !option.isLive)
registry.add(fastForward)
}
+2
View File
@@ -3,6 +3,8 @@
z-index: 9999;
width: 320px;
height: 180px;
max-width: 100vw;
max-height: 100vh;
background: #000;
border-radius: var(--art-border-radius);
cursor: move;