mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 23:51:20 -08:00
refactor(dpip): [PKG-DPIP-03] own windows and DOM resources in strict TypeScript
This commit is contained in:
1 parent
202351934b
commit
80d37d66f8
31 files changed
+2335
-597
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
import type { Disposer } from './resources'
|
||||
import { releaseAll } from './resources'
|
||||
|
||||
export function createControl(art: Artplayer, toggle: Disposer, alive: () => boolean) {
|
||||
const utils = (art.constructor as typeof Artplayer).utils
|
||||
const releases: Disposer[] = []
|
||||
let closed = false
|
||||
return {
|
||||
mount() {
|
||||
if (closed || !alive())
|
||||
return
|
||||
releases.push(() => art.controls.remove('document-pip'))
|
||||
art.controls.add({
|
||||
name: 'document-pip',
|
||||
position: 'right',
|
||||
index: 40,
|
||||
tooltip: art.i18n.get('PIP Mode'),
|
||||
mounted: (control) => {
|
||||
if (closed || !alive())
|
||||
return
|
||||
utils.append(control, art.icons.pip)
|
||||
const click = () => {
|
||||
if (alive())
|
||||
toggle()
|
||||
}
|
||||
// Explicit removal also covers legacy proxy registration that throws after binding.
|
||||
releases.push(() => control.removeEventListener('click', click))
|
||||
const unbind = art.proxy(control, 'click', click)
|
||||
if (closed)
|
||||
unbind()
|
||||
else
|
||||
releases.push(unbind)
|
||||
if (closed || !alive())
|
||||
return
|
||||
const update = (active: unknown) => {
|
||||
if (alive())
|
||||
utils.tooltip(control, art.i18n.get(active ? 'Exit PIP Mode' : 'PIP Mode'))
|
||||
}
|
||||
releases.push(() => art.off('document-pip', update))
|
||||
art.on('document-pip', update)
|
||||
},
|
||||
})
|
||||
if (closed)
|
||||
art.controls.remove('document-pip')
|
||||
},
|
||||
destroy() {
|
||||
if (closed)
|
||||
return
|
||||
closed = true
|
||||
const errors = releaseAll(releases.splice(0).reverse())
|
||||
if (errors.length)
|
||||
throw errors[0]
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import style from './style.less?inline'
|
||||
|
||||
export default function artplayerPluginDocumentPip(userOptions = {}) {
|
||||
const options = {
|
||||
width: 480,
|
||||
height: 270,
|
||||
fallbackToVideoPiP: true,
|
||||
placeholder: `Playing in Document Picture-in-Picture`,
|
||||
...userOptions,
|
||||
}
|
||||
|
||||
return (art) => {
|
||||
const isSupported = 'documentPictureInPicture' in window
|
||||
&& typeof window.documentPictureInPicture?.requestWindow === 'function'
|
||||
|
||||
const { proxy, icons, i18n, template: { $player }, constructor: { utils: { append, tooltip, addClass, removeClass, sleep } } } = art
|
||||
|
||||
const state = {
|
||||
win: null,
|
||||
originalParent: null,
|
||||
originalNext: null,
|
||||
currentDoc: null,
|
||||
placeholder: null,
|
||||
}
|
||||
|
||||
function copyStylesTo(targetDoc) {
|
||||
const base = targetDoc.createElement('style')
|
||||
base.textContent = `
|
||||
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
||||
#__art_dpip_root { position:absolute; inset:0; display:flex; }
|
||||
#__art_dpip_root > * { width:100% !important; height:100% !important; }
|
||||
`
|
||||
targetDoc.head.appendChild(base)
|
||||
if (!targetDoc.querySelector('meta[name="viewport"]')) {
|
||||
const meta = targetDoc.createElement('meta')
|
||||
meta.name = 'viewport'
|
||||
meta.content = 'width=device-width,initial-scale=1'
|
||||
targetDoc.head.appendChild(meta)
|
||||
}
|
||||
|
||||
try {
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
|
||||
const clone = targetDoc.createElement('link')
|
||||
clone.rel = 'stylesheet'
|
||||
clone.href = link.href
|
||||
if (link.media)
|
||||
clone.media = link.media
|
||||
if (link.crossOrigin)
|
||||
clone.crossOrigin = link.crossOrigin
|
||||
if (link.referrerPolicy)
|
||||
clone.referrerPolicy = link.referrerPolicy
|
||||
targetDoc.head.appendChild(clone)
|
||||
})
|
||||
}
|
||||
catch { }
|
||||
|
||||
try {
|
||||
document.querySelectorAll('style').forEach((style) => {
|
||||
const clone = targetDoc.createElement('style')
|
||||
clone.textContent = style.textContent
|
||||
targetDoc.head.appendChild(clone)
|
||||
})
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (!isSupported && options.fallbackToVideoPiP) {
|
||||
art.pip = true
|
||||
console.warn('[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.win)
|
||||
return
|
||||
|
||||
try {
|
||||
const pipWin = await window.documentPictureInPicture.requestWindow({
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
})
|
||||
|
||||
state.win = pipWin
|
||||
state.currentDoc = pipWin.document
|
||||
|
||||
state.placeholder = document.createElement('div')
|
||||
state.placeholder.className = 'artplayer-document-pip-placeholder'
|
||||
state.placeholder.style.cssText = `display:flex;justify-content:center;align-items:center;width:100%;height:100%;`
|
||||
state.placeholder.textContent = options.placeholder
|
||||
state.originalParent = $player.parentNode
|
||||
state.originalNext = $player.nextSibling
|
||||
state.originalParent.insertBefore(state.placeholder, state.originalNext)
|
||||
|
||||
const root = pipWin.document.createElement('div')
|
||||
root.id = '__art_dpip_root'
|
||||
pipWin.document.body.appendChild(root)
|
||||
|
||||
copyStylesTo(pipWin.document)
|
||||
|
||||
const adopted = pipWin.document.adoptNode($player)
|
||||
root.appendChild(adopted)
|
||||
state.currentDoc = pipWin.document
|
||||
|
||||
const onResize = () => art.resize?.()
|
||||
const onClose = () => close()
|
||||
|
||||
pipWin.addEventListener('resize', onResize)
|
||||
pipWin.addEventListener('pagehide', onClose)
|
||||
pipWin.addEventListener('unload', onClose)
|
||||
|
||||
state.cleanup = () => {
|
||||
pipWin.removeEventListener('resize', onResize)
|
||||
pipWin.removeEventListener('pagehide', onClose)
|
||||
pipWin.removeEventListener('unload', onClose)
|
||||
}
|
||||
|
||||
addClass($player, 'artplayer-document-pip')
|
||||
art.events.bindGlobalEvents?.()
|
||||
art.emit('document-pip', true)
|
||||
await sleep(100)
|
||||
art.emit('resize')
|
||||
}
|
||||
catch (err) {
|
||||
art.notice.show = 'Document Picture-in-Picture open failed'
|
||||
console.warn('[artplayer-plugin-document-pip] open failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreToOriginalDocument() {
|
||||
if (!state.placeholder)
|
||||
return
|
||||
const backDoc = state.placeholder.ownerDocument || document
|
||||
const adoptedBack = backDoc.adoptNode($player)
|
||||
state.originalParent.insertBefore(adoptedBack, state.placeholder)
|
||||
state.placeholder.remove()
|
||||
state.placeholder = null
|
||||
state.currentDoc = backDoc
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (!state.win)
|
||||
return
|
||||
try {
|
||||
state.cleanup?.()
|
||||
restoreToOriginalDocument()
|
||||
state.win.close()
|
||||
state.win = null
|
||||
removeClass($player, 'artplayer-document-pip')
|
||||
art.events.bindGlobalEvents?.()
|
||||
art.emit('document-pip', false)
|
||||
await sleep(100)
|
||||
art.emit('resize')
|
||||
}
|
||||
catch (err) {
|
||||
art.notice.show = 'Document Picture-in-Picture close failed'
|
||||
console.warn('[artplayer-plugin-document-pip] close failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (state.win) {
|
||||
close()
|
||||
}
|
||||
else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
art.controls.add({
|
||||
name: 'document-pip',
|
||||
position: 'right',
|
||||
index: 40,
|
||||
tooltip: art.i18n.get('PIP Mode'),
|
||||
mounted: ($control) => {
|
||||
append($control, icons.pip)
|
||||
proxy($control, 'click', toggle)
|
||||
art.on('document-pip', (value) => {
|
||||
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
art.on('destroy', close)
|
||||
|
||||
return {
|
||||
name: 'artplayerPluginDocumentPip',
|
||||
get isSupported() { return isSupported },
|
||||
get isActive() { return !!state.win },
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const id = 'artplayer-plugin-document-pip'
|
||||
let $style = document.getElementById(id)
|
||||
if (!$style) {
|
||||
$style = document.createElement('style')
|
||||
$style.id = id
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.head.appendChild($style)
|
||||
})
|
||||
}
|
||||
else {
|
||||
(document.head || document.documentElement).appendChild($style)
|
||||
}
|
||||
}
|
||||
$style.textContent = style
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
import type PublicPlugin from '../types/artplayer-plugin-document-pip'
|
||||
import type { WindowApi } from './window-session'
|
||||
import { createControl } from './control'
|
||||
import { createProjection } from './projection'
|
||||
import { releaseAll } from './resources'
|
||||
import { installStyle } from './styles'
|
||||
import { createWindowSession } from './window-session'
|
||||
|
||||
type Option = Parameters<typeof PublicPlugin>[0]
|
||||
|
||||
export default function artplayerPluginDocumentPip(userOptions: Option = {}) {
|
||||
const options = { width: 480, height: 270, fallbackToVideoPiP: true, placeholder: 'Playing in Document Picture-in-Picture', ...userOptions }
|
||||
return (art: Artplayer) => {
|
||||
const browser = window as Window & { documentPictureInPicture?: WindowApi }
|
||||
const isSupported = 'documentPictureInPicture' in browser && typeof browser.documentPictureInPicture?.requestWindow === 'function'
|
||||
let host: (Artplayer & { resize?: () => void }) | null = art
|
||||
let player: HTMLElement | null = art.template.$player
|
||||
let closed = art.isDestroy
|
||||
const utils = (art.constructor as typeof Artplayer).utils
|
||||
const alive = () => !closed && !!host && !host.isDestroy
|
||||
const session = createWindowSession({
|
||||
alive,
|
||||
request: () => browser.documentPictureInPicture!.requestWindow({ width: options.width, height: options.height }),
|
||||
project: document => createProjection(player!, document, options.placeholder),
|
||||
activate(active) {
|
||||
if (!host || !player)
|
||||
return
|
||||
if (active)
|
||||
utils.addClass(player, 'artplayer-document-pip')
|
||||
else
|
||||
utils.removeClass(player, 'artplayer-document-pip')
|
||||
if (!alive())
|
||||
return
|
||||
host.events.bindGlobalEvents?.()
|
||||
if (alive())
|
||||
host.emit('document-pip', active)
|
||||
},
|
||||
resize: () => {
|
||||
if (alive())
|
||||
host!.emit('resize')
|
||||
},
|
||||
nativeResize: () => {
|
||||
if (alive())
|
||||
host!.resize?.()
|
||||
},
|
||||
report(action, error) {
|
||||
if (!alive())
|
||||
return
|
||||
host!.notice.show = `Document Picture-in-Picture ${action} failed`
|
||||
console.warn(`[artplayer-plugin-document-pip] ${action} failed:`, error)
|
||||
},
|
||||
})
|
||||
async function open() {
|
||||
if (!alive())
|
||||
return
|
||||
if (!isSupported && options.fallbackToVideoPiP) {
|
||||
host!.pip = true
|
||||
console.warn('[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture')
|
||||
return
|
||||
}
|
||||
return session.open()
|
||||
}
|
||||
function toggle() {
|
||||
if (!alive())
|
||||
return
|
||||
if (session.active || session.opening)
|
||||
void session.close()
|
||||
else
|
||||
void open()
|
||||
}
|
||||
let control: ReturnType<typeof createControl> | null = createControl(art, toggle, alive)
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return
|
||||
closed = true
|
||||
const current = host
|
||||
const previousControl = control
|
||||
control = null
|
||||
const errors = releaseAll([session.destroy, () => previousControl?.destroy(), () => current?.off('destroy', destroy)])
|
||||
host = null
|
||||
player = null
|
||||
if (errors.length)
|
||||
console.warn('[artplayer-plugin-document-pip] cleanup failed:', errors[0])
|
||||
}
|
||||
const result = {
|
||||
name: 'artplayerPluginDocumentPip' as const,
|
||||
get isSupported() { return isSupported },
|
||||
get isActive() { return session.active },
|
||||
open,
|
||||
close: session.close,
|
||||
toggle,
|
||||
}
|
||||
if (closed) {
|
||||
host = null
|
||||
player = null
|
||||
return result
|
||||
}
|
||||
try {
|
||||
art.on('destroy', destroy)
|
||||
if (alive())
|
||||
control?.mount()
|
||||
else
|
||||
art.off('destroy', destroy)
|
||||
}
|
||||
catch (error) {
|
||||
destroy()
|
||||
throw error
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined')
|
||||
installStyle(document)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { releaseAll } from './resources'
|
||||
import { copyStyles } from './styles'
|
||||
|
||||
export function createProjection(player: HTMLElement, target: Document, text: string | undefined) {
|
||||
const source = player.ownerDocument
|
||||
const parent = player.parentNode
|
||||
const next = player.nextSibling
|
||||
if (!parent)
|
||||
throw new Error('Document Picture-in-Picture requires a mounted player')
|
||||
const placeholder = source.createElement('div')
|
||||
placeholder.className = 'artplayer-document-pip-placeholder'
|
||||
placeholder.style.cssText = 'display:flex;justify-content:center;align-items:center;width:100%;height:100%;'
|
||||
placeholder.textContent = text ?? ''
|
||||
const root = target.createElement('div')
|
||||
root.id = '__art_dpip_root'
|
||||
const nodes: HTMLElement[] = []
|
||||
let closed = false
|
||||
const restore = () => {
|
||||
// Insertion adopts atomically; never detach the player before validating the anchor.
|
||||
const anchor = placeholder.parentNode === parent ? placeholder : next?.parentNode === parent ? next : null
|
||||
if (player.parentNode !== parent || player.nextSibling !== anchor) {
|
||||
try {
|
||||
parent.insertBefore(player, anchor)
|
||||
}
|
||||
catch (error) {
|
||||
// Keep a recoverable node in the original document if its parent rejects insertion.
|
||||
source.body.appendChild(player)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleanup = () => {
|
||||
const errors = releaseAll([restore, () => placeholder.remove(), ...nodes.splice(0).map(node => () => node.remove()), () => root.remove()])
|
||||
if (errors.length)
|
||||
throw errors[0]
|
||||
}
|
||||
return {
|
||||
mount() {
|
||||
if (closed)
|
||||
return
|
||||
parent.insertBefore(placeholder, next?.parentNode === parent ? next : null)
|
||||
if (!closed)
|
||||
target.body.appendChild(root)
|
||||
if (!closed)
|
||||
copyStyles(source, target, node => nodes.push(node), () => !closed)
|
||||
if (!closed) {
|
||||
const adopted = target.adoptNode(player)
|
||||
if (!closed)
|
||||
root.appendChild(adopted)
|
||||
}
|
||||
if (closed)
|
||||
cleanup()
|
||||
},
|
||||
destroy() {
|
||||
if (closed)
|
||||
return
|
||||
closed = true
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type Disposer = () => void
|
||||
|
||||
export function releaseAll(releases: Disposer[]): unknown[] {
|
||||
const errors: unknown[] = []
|
||||
for (const release of releases) {
|
||||
try {
|
||||
release()
|
||||
}
|
||||
catch (error) { errors.push(error) }
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function createDelay() {
|
||||
let finish!: () => void
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
timer = null
|
||||
finish()
|
||||
}, 100)
|
||||
return {
|
||||
promise,
|
||||
cancel() {
|
||||
if (timer !== null)
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
finish()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import style from './style.less?inline'
|
||||
|
||||
export function installStyle(document: Document): void {
|
||||
const id = 'artplayer-plugin-document-pip'
|
||||
const existing = document.getElementById(id)
|
||||
if (existing) {
|
||||
existing.textContent = style
|
||||
return
|
||||
}
|
||||
const element = document.createElement('style')
|
||||
element.id = id
|
||||
element.textContent = style
|
||||
const insert = () => {
|
||||
document.removeEventListener('DOMContentLoaded', insert)
|
||||
const current = document.getElementById(id)
|
||||
if (current)
|
||||
current.textContent = style
|
||||
else
|
||||
(document.head || document.documentElement).appendChild(element)
|
||||
}
|
||||
if (document.readyState === 'loading')
|
||||
document.addEventListener('DOMContentLoaded', insert)
|
||||
else
|
||||
insert()
|
||||
}
|
||||
|
||||
export function copyStyles(source: Document, target: Document, own: (node: HTMLElement) => void, alive: () => boolean): void {
|
||||
const append = (node: HTMLElement) => {
|
||||
if (!alive())
|
||||
return
|
||||
own(node)
|
||||
target.head.appendChild(node)
|
||||
if (!alive())
|
||||
node.remove()
|
||||
}
|
||||
const base = target.createElement('style')
|
||||
base.textContent = `
|
||||
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
||||
#__art_dpip_root { position:absolute; inset:0; display:flex; }
|
||||
#__art_dpip_root > * { width:100% !important; height:100% !important; }
|
||||
`
|
||||
append(base)
|
||||
if (!target.querySelector('meta[name="viewport"]')) {
|
||||
const meta = target.createElement('meta')
|
||||
meta.name = 'viewport'
|
||||
meta.content = 'width=device-width,initial-scale=1'
|
||||
append(meta)
|
||||
}
|
||||
try {
|
||||
source.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]').forEach((link) => {
|
||||
const clone = target.createElement('link')
|
||||
clone.rel = 'stylesheet'
|
||||
clone.href = link.href
|
||||
if (link.media)
|
||||
clone.media = link.media
|
||||
if (link.crossOrigin)
|
||||
clone.crossOrigin = link.crossOrigin
|
||||
if (link.referrerPolicy)
|
||||
clone.referrerPolicy = link.referrerPolicy
|
||||
append(clone)
|
||||
})
|
||||
}
|
||||
catch { /* Preserve best-effort copying when stylesheets are inaccessible. */ }
|
||||
try {
|
||||
source.querySelectorAll('style').forEach((element) => {
|
||||
const clone = target.createElement('style')
|
||||
clone.textContent = element.textContent
|
||||
append(clone)
|
||||
})
|
||||
}
|
||||
catch { /* A page may restrict access to its style nodes. */ }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Disposer } from './resources'
|
||||
import { createDelay, releaseAll } from './resources'
|
||||
|
||||
export interface WindowApi {
|
||||
requestWindow: (option: { width?: number, height?: number }) => Promise<Window>
|
||||
}
|
||||
|
||||
interface Hooks {
|
||||
alive: () => boolean
|
||||
request: () => Promise<Window>
|
||||
project: (document: Document) => { mount: () => void, destroy: Disposer }
|
||||
activate: (active: boolean) => void
|
||||
resize: Disposer
|
||||
nativeResize: Disposer
|
||||
report: (action: 'open' | 'close', error: unknown) => void
|
||||
}
|
||||
|
||||
interface Session {
|
||||
window: Window
|
||||
releases: Disposer[]
|
||||
announced: boolean
|
||||
}
|
||||
|
||||
export function createWindowSession(hooks: Hooks) {
|
||||
let destroyed = false
|
||||
let revision = 0
|
||||
let session: Session | null = null
|
||||
let pending: { promise: Promise<void>, finish: Disposer } | null = null
|
||||
let delay: ReturnType<typeof createDelay> | null = null
|
||||
const alive = () => !destroyed && hooks.alive()
|
||||
const valid = (value: number) => value === revision && alive()
|
||||
function invalidate() {
|
||||
revision++
|
||||
pending?.finish()
|
||||
pending = null
|
||||
delay?.cancel()
|
||||
delay = null
|
||||
}
|
||||
function dispose(current: Session): unknown[] {
|
||||
const errors = releaseAll(current.releases.splice(0))
|
||||
errors.push(...releaseAll([() => current.window.close()]))
|
||||
return errors
|
||||
}
|
||||
async function resizeLater(value: number) {
|
||||
if (!valid(value))
|
||||
return
|
||||
const current = createDelay()
|
||||
delay = current
|
||||
await current.promise
|
||||
if (delay === current)
|
||||
delay = null
|
||||
if (valid(value))
|
||||
hooks.resize()
|
||||
}
|
||||
async function close() {
|
||||
invalidate()
|
||||
const value = revision
|
||||
const current = session
|
||||
session = null
|
||||
if (!current)
|
||||
return
|
||||
const errors = dispose(current)
|
||||
if (current.announced) {
|
||||
try {
|
||||
hooks.activate(false)
|
||||
}
|
||||
catch (error) { errors.push(error) }
|
||||
}
|
||||
if (errors.length && alive())
|
||||
hooks.report('close', errors[0])
|
||||
if (valid(value))
|
||||
await resizeLater(value)
|
||||
}
|
||||
async function open() {
|
||||
if (!alive() || session)
|
||||
return
|
||||
if (pending)
|
||||
return pending.promise
|
||||
invalidate()
|
||||
const value = revision
|
||||
let finish!: Disposer
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<void>((resolve, fail) => {
|
||||
finish = resolve
|
||||
reject = fail
|
||||
})
|
||||
const ticket = { promise, finish }
|
||||
pending = ticket
|
||||
const settle = () => {
|
||||
if (pending === ticket)
|
||||
pending = null
|
||||
finish()
|
||||
}
|
||||
const failed = (error: unknown) => {
|
||||
if (valid(value)) {
|
||||
const current = session
|
||||
session = null
|
||||
if (current) {
|
||||
dispose(current)
|
||||
if (current.announced)
|
||||
releaseAll([() => hooks.activate(false)])
|
||||
}
|
||||
try {
|
||||
hooks.report('open', error)
|
||||
}
|
||||
catch (noticeError) { reject(noticeError) }
|
||||
}
|
||||
}
|
||||
const receive = async (window: Window) => {
|
||||
if (!valid(value)) {
|
||||
// Only the window is retained by a stale request, never a live projection.
|
||||
if (session?.window !== window)
|
||||
releaseAll([() => window.close()])
|
||||
return
|
||||
}
|
||||
const current: Session = { window, releases: [], announced: false }
|
||||
session = current
|
||||
try {
|
||||
const projection = hooks.project(window.document)
|
||||
current.releases.push(projection.destroy)
|
||||
projection.mount()
|
||||
if (!valid(value)) {
|
||||
dispose(current)
|
||||
return
|
||||
}
|
||||
for (const [event, callback] of [['resize', () => {
|
||||
if (valid(value))
|
||||
hooks.nativeResize()
|
||||
}], ['pagehide', () => {
|
||||
void close()
|
||||
}], ['unload', () => {
|
||||
void close()
|
||||
}]] as const) {
|
||||
current.releases.unshift(() => window.removeEventListener(event, callback))
|
||||
window.addEventListener(event, callback)
|
||||
if (!valid(value)) {
|
||||
dispose(current)
|
||||
return
|
||||
}
|
||||
}
|
||||
current.announced = true
|
||||
hooks.activate(true)
|
||||
await resizeLater(value)
|
||||
}
|
||||
catch (error) { failed(error) }
|
||||
}
|
||||
try {
|
||||
// Called directly while the caller's user activation is still available.
|
||||
const request = hooks.request()
|
||||
void Promise.resolve(request).then(receive, failed).then(settle, (error) => {
|
||||
if (valid(value))
|
||||
reject(error)
|
||||
settle()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
failed(error)
|
||||
}
|
||||
finally {
|
||||
settle()
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
return {
|
||||
get active() { return session !== null },
|
||||
get opening() { return pending !== null },
|
||||
open,
|
||||
close,
|
||||
destroy() {
|
||||
if (destroyed)
|
||||
return
|
||||
destroyed = true
|
||||
// close performs synchronous restoration; its delayed effects are now disabled.
|
||||
void close().catch(() => {})
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user