mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-20 20:21:20 -08:00
refactor(core): CORE-04 scope initialization and make destruction reentrant-safe
This commit is contained in:
1 parent
3e38dd0d19
commit
4eec8112fa
32 files changed
+2779
-189
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import { wait } from '../lifecycle/resources'
|
||||
import {
|
||||
addClass,
|
||||
append,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
inverseClass,
|
||||
isMobile,
|
||||
removeClass,
|
||||
sleep,
|
||||
} from '../utils'
|
||||
import Component from '../utils/component'
|
||||
import airplay from './airplay'
|
||||
@@ -127,7 +128,9 @@ export default class Control extends Component {
|
||||
}
|
||||
|
||||
if (option.quality.length) {
|
||||
sleep().then(() => {
|
||||
wait(getScope(this.art)).then((active) => {
|
||||
if (!active || getScope(this.art).closed)
|
||||
return
|
||||
this.art.quality = option.quality
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import clickInit from './clickInit'
|
||||
import gestureInit from './gestureInit'
|
||||
import globalInit from './globalInit'
|
||||
@@ -7,9 +8,14 @@ import resizeInit from './resizeInit'
|
||||
import updateInit from './updateInit'
|
||||
import viewInit from './viewInit'
|
||||
|
||||
const scopes = new WeakMap()
|
||||
|
||||
export default class Events {
|
||||
constructor(art) {
|
||||
this.destroyEvents = new Set()
|
||||
const scope = getScope(art)
|
||||
scopes.set(this, scope)
|
||||
scope.add(() => this.destroy())
|
||||
this.proxy = this.proxy.bind(this)
|
||||
this.hover = this.hover.bind(this)
|
||||
|
||||
@@ -28,6 +34,9 @@ export default class Events {
|
||||
return name.map(item => this.proxy(target, item, callback, option))
|
||||
}
|
||||
|
||||
if (scopes.get(this).closed)
|
||||
return () => {}
|
||||
|
||||
target.addEventListener(name, callback, option)
|
||||
const destroy = () => target.removeEventListener(name, callback, option)
|
||||
this.destroyEvents.add(destroy)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { debounce } from '../utils'
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import { timeout } from '../lifecycle/resources'
|
||||
|
||||
export default function resizeInit(art, events) {
|
||||
const { option, constructor } = art
|
||||
@@ -12,7 +13,12 @@ export default function resizeInit(art, events) {
|
||||
notice.show = ''
|
||||
})
|
||||
|
||||
const resizeFn = debounce(() => art.emit('resize'), constructor.RESIZE_TIME)
|
||||
const scope = getScope(art)
|
||||
let cancel = () => {}
|
||||
const resizeFn = () => {
|
||||
cancel()
|
||||
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
|
||||
}
|
||||
|
||||
art.on('window:orientationchange', () => resizeFn())
|
||||
art.on('window:resize', () => resizeFn())
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import { animationFrame } from '../lifecycle/resources'
|
||||
|
||||
export default function updateInit(art) {
|
||||
if (art.constructor.USE_RAF) {
|
||||
let timer = null;
|
||||
const scope = getScope(art)
|
||||
let cancel = () => {};
|
||||
|
||||
(function update() {
|
||||
if (art.playing) {
|
||||
@@ -8,12 +12,12 @@ export default function updateInit(art) {
|
||||
}
|
||||
|
||||
if (!art.isDestroy) {
|
||||
timer = requestAnimationFrame(update)
|
||||
cancel = animationFrame(scope, update)
|
||||
}
|
||||
})()
|
||||
|
||||
art.on('destroy', () => {
|
||||
cancelAnimationFrame(timer)
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import I18n from './i18n'
|
||||
import Icons from './icons'
|
||||
import Info from './info'
|
||||
import Layer from './layer'
|
||||
import { beginLifecycle, destroyInstance, finishLifecycle, getScope } from './lifecycle/instance'
|
||||
import Loading from './loading'
|
||||
import Mask from './mask'
|
||||
import Notice from './notice'
|
||||
@@ -47,38 +48,54 @@ export default class Artplayer extends Emitter {
|
||||
this.isRotate = false
|
||||
this.isDestroy = false
|
||||
|
||||
this.template = new Template(this)
|
||||
this.events = new Events(this)
|
||||
this.storage = new Storage(this)
|
||||
this.icons = new Icons(this)
|
||||
this.i18n = new I18n(this)
|
||||
this.notice = new Notice(this)
|
||||
this.player = new Player(this)
|
||||
this.layers = new Layer(this)
|
||||
this.controls = new Control(this)
|
||||
this.contextmenu = new Contextmenu(this)
|
||||
this.subtitle = new Subtitle(this)
|
||||
this.info = new Info(this)
|
||||
this.loading = new Loading(this)
|
||||
this.hotkey = new Hotkey(this)
|
||||
this.mask = new Mask(this)
|
||||
this.setting = new Setting(this)
|
||||
this.plugins = new Plugins(this)
|
||||
beginLifecycle(this)
|
||||
try {
|
||||
this.template = new Template(this)
|
||||
this.events = new Events(this)
|
||||
this.storage = new Storage(this)
|
||||
this.icons = new Icons(this)
|
||||
this.i18n = new I18n(this)
|
||||
this.notice = new Notice(this)
|
||||
this.player = new Player(this)
|
||||
this.layers = new Layer(this)
|
||||
this.controls = new Control(this)
|
||||
this.contextmenu = new Contextmenu(this)
|
||||
this.subtitle = new Subtitle(this)
|
||||
this.info = new Info(this)
|
||||
this.loading = new Loading(this)
|
||||
this.hotkey = new Hotkey(this)
|
||||
this.mask = new Mask(this)
|
||||
this.setting = new Setting(this)
|
||||
this.plugins = new Plugins(this)
|
||||
|
||||
if (typeof readyCallback === 'function') {
|
||||
this.on('ready', () => readyCallback.call(this, this))
|
||||
}
|
||||
if (getScope(this).closed)
|
||||
return
|
||||
|
||||
if (Artplayer.DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
const log = msg => console.log(`[ART.${this.id}] -> ${msg}`)
|
||||
log(`Version@${Artplayer.version}`)
|
||||
for (let index = 0; index < config.events.length; index++) {
|
||||
this.on(`video:${config.events[index]}`, event => log(`Event@${event.type}`))
|
||||
if (typeof readyCallback === 'function') {
|
||||
this.on('ready', () => readyCallback.call(this, this))
|
||||
}
|
||||
}
|
||||
|
||||
instances.push(this)
|
||||
if (Artplayer.DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
const log = msg => console.log(`[ART.${this.id}] -> ${msg}`)
|
||||
log(`Version@${Artplayer.version}`)
|
||||
for (let index = 0; index < config.events.length; index++) {
|
||||
this.on(`video:${config.events[index]}`, event => log(`Event@${event.type}`))
|
||||
}
|
||||
}
|
||||
|
||||
if (finishLifecycle(this))
|
||||
instances.push(this)
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
destroyInstance(this, instances, true, Artplayer.REMOVE_SRC_WHEN_DESTROY, true)
|
||||
}
|
||||
catch (cleanupError) {
|
||||
console.warn('Failed to clean up ArtPlayer initialization:', cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static get instances() {
|
||||
@@ -208,14 +225,7 @@ export default class Artplayer extends Emitter {
|
||||
}
|
||||
|
||||
destroy(removeHtml = true) {
|
||||
if (Artplayer.REMOVE_SRC_WHEN_DESTROY) {
|
||||
this.reset()
|
||||
}
|
||||
this.events.destroy()
|
||||
this.template.destroy(removeHtml)
|
||||
instances.splice(instances.indexOf(this), 1)
|
||||
this.isDestroy = true
|
||||
this.emit('destroy')
|
||||
destroyInstance(this, instances, removeHtml, Artplayer.REMOVE_SRC_WHEN_DESTROY)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getScope } from './lifecycle/instance'
|
||||
import { timeout } from './lifecycle/resources'
|
||||
import { isMobile, queryAll } from './utils'
|
||||
import Component from './utils/component'
|
||||
|
||||
@@ -22,9 +24,10 @@ export default class Info extends Component {
|
||||
this.show = false
|
||||
})
|
||||
|
||||
let timer = null
|
||||
let cancel = () => {}
|
||||
const $types = queryAll('[data-video]', $infoPanel) || []
|
||||
this.art.on('destroy', () => clearTimeout(timer))
|
||||
this.art.on('destroy', () => cancel())
|
||||
const scope = getScope(this.art)
|
||||
|
||||
function loop() {
|
||||
for (let index = 0; index < $types.length; index++) {
|
||||
@@ -35,7 +38,7 @@ export default class Info extends Component {
|
||||
item.textContent = textContent
|
||||
}
|
||||
}
|
||||
timer = setTimeout(loop, constructor.INFO_LOOP_TIME)
|
||||
cancel = timeout(scope, loop, constructor.INFO_LOOP_TIME)
|
||||
}
|
||||
|
||||
loop()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import ResourceScope, { ResourceCleanupError } from './scope'
|
||||
|
||||
interface LifecycleOwner {
|
||||
isDestroy: boolean
|
||||
reset: () => void
|
||||
emit: (name: 'destroy') => unknown
|
||||
template?: { $video?: unknown, destroy: (removeHtml: boolean) => void }
|
||||
}
|
||||
|
||||
interface State {
|
||||
scope: ResourceScope
|
||||
destroying: boolean
|
||||
rollback?: () => void
|
||||
releaseContainer?: () => void
|
||||
}
|
||||
|
||||
const states = new WeakMap<object, State>()
|
||||
const containers = new WeakMap<Element, object>()
|
||||
|
||||
export function beginLifecycle(owner: object): void {
|
||||
states.set(owner, { scope: new ResourceScope(), destroying: false })
|
||||
}
|
||||
|
||||
function stateOf(owner: object): State {
|
||||
const state = states.get(owner)
|
||||
if (!state)
|
||||
throw new Error('ArtPlayer lifecycle has not been initialized')
|
||||
return state
|
||||
}
|
||||
|
||||
export function getScope(owner: object): ResourceScope {
|
||||
return stateOf(owner).scope
|
||||
}
|
||||
|
||||
export function ownContainer(owner: object, container: Element, rollback: () => void): void {
|
||||
const current = containers.get(container)
|
||||
if (current && current !== owner)
|
||||
throw new Error('Cannot mount multiple instances on the same dom element')
|
||||
const state = stateOf(owner)
|
||||
state.rollback = rollback
|
||||
containers.set(container, owner)
|
||||
state.releaseContainer = () => {
|
||||
if (containers.get(container) === owner)
|
||||
containers.delete(container)
|
||||
}
|
||||
}
|
||||
|
||||
export function finishLifecycle(owner: object): boolean {
|
||||
const state = stateOf(owner)
|
||||
state.rollback = undefined
|
||||
return !state.scope.closed
|
||||
}
|
||||
|
||||
export function destroyInstance(owner: LifecycleOwner, instances: LifecycleOwner[], removeHtml: boolean, removeSource: boolean, failed = false): void {
|
||||
const state = stateOf(owner)
|
||||
if (state.destroying || state.scope.closed)
|
||||
return
|
||||
state.destroying = true
|
||||
const errors: unknown[] = []
|
||||
const attempt = (cleanup: () => void) => {
|
||||
try {
|
||||
cleanup()
|
||||
}
|
||||
catch (error) {
|
||||
errors.push(...(error instanceof ResourceCleanupError ? error.errors : [error]))
|
||||
}
|
||||
}
|
||||
if (removeSource && owner.template?.$video)
|
||||
attempt(() => owner.reset())
|
||||
attempt(() => state.scope.dispose())
|
||||
attempt(() => owner.template?.destroy(removeHtml))
|
||||
const index = instances.indexOf(owner)
|
||||
if (index !== -1)
|
||||
instances.splice(index, 1)
|
||||
owner.isDestroy = true
|
||||
if (!failed) {
|
||||
state.releaseContainer?.()
|
||||
state.releaseContainer = undefined
|
||||
}
|
||||
attempt(() => owner.emit('destroy'))
|
||||
if (failed && state.rollback)
|
||||
attempt(state.rollback)
|
||||
state.rollback = undefined
|
||||
state.releaseContainer?.()
|
||||
state.releaseContainer = undefined
|
||||
// Preserve the first thrown value while making additional failures observable.
|
||||
for (const error of errors.slice(1))
|
||||
console.warn('Additional ArtPlayer cleanup failure:', error)
|
||||
if (errors.length)
|
||||
throw errors[0]
|
||||
}
|
||||
@@ -83,3 +83,22 @@ export function objectURL(scope: ResourceScope, blob: Blob): string {
|
||||
})
|
||||
return url
|
||||
}
|
||||
|
||||
export function wait(scope: ResourceScope, delay = 0): Promise<boolean> {
|
||||
if (scope.closed)
|
||||
return Promise.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
let completed = false
|
||||
let release = () => {}
|
||||
const timer = setTimeout(() => {
|
||||
completed = true
|
||||
release()
|
||||
resolve(true)
|
||||
}, delay)
|
||||
release = scope.add(() => {
|
||||
clearTimeout(timer)
|
||||
if (!completed)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
interface Snapshot {
|
||||
node: Node
|
||||
children: Node[]
|
||||
attributes?: [string, string][]
|
||||
value: string | null
|
||||
}
|
||||
|
||||
export default function captureTemplate(container: Element): () => void {
|
||||
const snapshots: Snapshot[] = []
|
||||
function capture(node: Node) {
|
||||
const children = Array.from(node.childNodes)
|
||||
snapshots.push({
|
||||
node,
|
||||
children,
|
||||
attributes: node.nodeType === 1 ? Array.from((node as Element).attributes, attr => [attr.name, attr.value]) : undefined,
|
||||
value: node.nodeValue,
|
||||
})
|
||||
children.forEach(capture)
|
||||
}
|
||||
capture(container)
|
||||
return () => {
|
||||
for (const { node, children, attributes, value } of snapshots) {
|
||||
while (node.firstChild)
|
||||
node.removeChild(node.firstChild)
|
||||
for (const child of children)
|
||||
node.appendChild(child)
|
||||
if (attributes) {
|
||||
const element = node as Element
|
||||
for (const attr of Array.from(element.attributes))
|
||||
element.removeAttribute(attr.name)
|
||||
for (const [name, content] of attributes)
|
||||
element.setAttribute(name, content)
|
||||
}
|
||||
else {
|
||||
node.nodeValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getScope } from './lifecycle/instance'
|
||||
import { addClass, removeClass } from './utils'
|
||||
|
||||
export default class Notice {
|
||||
constructor(art) {
|
||||
this.art = art
|
||||
this.timer = null
|
||||
getScope(art).add(() => this.destroy())
|
||||
|
||||
art.on('destroy', () => this.destroy())
|
||||
}
|
||||
@@ -16,6 +18,8 @@ export default class Notice {
|
||||
}
|
||||
|
||||
set show(msg) {
|
||||
if (getScope(this.art).closed)
|
||||
return
|
||||
const {
|
||||
constructor,
|
||||
template: { $player, $noticeInner },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import config from '../config'
|
||||
import { addClass, isMobile, setStyle, silencePromise, sleep } from '../utils'
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import { wait } from '../lifecycle/resources'
|
||||
import { addClass, isMobile, setStyle, silencePromise } from '../utils'
|
||||
|
||||
export default function eventInit(art) {
|
||||
const {
|
||||
@@ -63,7 +65,8 @@ export default function eventInit(art) {
|
||||
|
||||
art.on('video:error', async (error) => {
|
||||
if (reconnectTime < constructor.RECONNECT_TIME_MAX) {
|
||||
await sleep(constructor.RECONNECT_SLEEP_TIME)
|
||||
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
|
||||
return
|
||||
reconnectTime += 1
|
||||
art.url = option.url
|
||||
notice.show = `${i18n.get('Reconnect')}: ${reconnectTime}`
|
||||
@@ -74,7 +77,8 @@ export default function eventInit(art) {
|
||||
art.loading.show = false
|
||||
art.controls.show = true
|
||||
addClass($player, 'art-error')
|
||||
await sleep(constructor.RECONNECT_SLEEP_TIME)
|
||||
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
|
||||
return
|
||||
notice.show = i18n.get('Video Load Failed')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { def, getExt, sleep } from '../utils'
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import { wait } from '../lifecycle/resources'
|
||||
import { def, getExt } from '../utils'
|
||||
|
||||
export default function urlMix(art) {
|
||||
const {
|
||||
@@ -17,7 +19,8 @@ export default function urlMix(art) {
|
||||
const typeCallback = option.customType[typeName]
|
||||
|
||||
if (typeName && typeCallback) {
|
||||
await sleep()
|
||||
if (!await wait(getScope(art)) || getScope(art).closed)
|
||||
return
|
||||
art.loading.show = true
|
||||
typeCallback.call(art, $video, newUrl, art)
|
||||
}
|
||||
@@ -36,7 +39,8 @@ export default function urlMix(art) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
await sleep()
|
||||
if (!await wait(getScope(art)) || getScope(art).closed)
|
||||
return
|
||||
art.loading.show = true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
import { timeout } from '../lifecycle/resources'
|
||||
import {
|
||||
addClass,
|
||||
append,
|
||||
@@ -526,7 +528,7 @@ export default class Setting extends Component {
|
||||
}
|
||||
|
||||
if (item.mounted) {
|
||||
setTimeout(() => item.mounted.call(this.art, item.$item, item), 0)
|
||||
timeout(getScope(this.art), () => item.mounted.call(this.art, item.$item, item), 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { version } from '../package.json'
|
||||
import { ownContainer } from './lifecycle/instance'
|
||||
import captureTemplate from './lifecycle/template-rollback'
|
||||
import { addClass, errorHandle, isMobile, query, replaceElement, supportsFlex } from './utils'
|
||||
|
||||
export default class Template {
|
||||
@@ -25,6 +27,7 @@ export default class Template {
|
||||
)
|
||||
|
||||
this.query = this.query.bind(this)
|
||||
ownContainer(art, this.$container, captureTemplate(this.$container))
|
||||
this.$container.dataset.artId = art.id
|
||||
this.init()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user