refactor(vast): [PKG-VAST-03] isolate lifecycle ownership checkpoint

This commit is contained in:
Harvey Zhao committed 2026-09-12 09:33:54 +08:00
1 parent d5e1feadf5
commit 4f324a8438
30 files changed
+1453 -383

No files matched your search

-130
View File
@@ -1,130 +0,0 @@
import { loadImaSdk, Player, PlayerOptions } from '@glomex/vast-ima-player'
export default function artplayerPluginVast(callback) {
return async (art) => {
const { template, constructor } = art
const { createElement, setStyles } = constructor.utils
const { $video, $player } = template
await loadImaSdk()
const google = window.google
const ima = google.ima
const adsRenderingSettings = new ima.AdsRenderingSettings()
adsRenderingSettings.restoreCustomPlaybackStateOnAdBreakComplete = true
adsRenderingSettings.enablePreloading = true
const playerOptions = new PlayerOptions()
let isAdPlaying = false
let imaPlayer = null
let $container = null
function createContainer() {
const container = createElement('div')
const id = `art-vast-${Date.now()}`
container.id = id
setStyles(container, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
zIndex: '150',
backgroundColor: 'black',
display: 'none',
pointerEvents: 'auto',
})
return container
}
function initPlayer() {
if (imaPlayer)
return imaPlayer
$container = createContainer()
$player.appendChild($container)
imaPlayer = new Player(ima, $video, $container, adsRenderingSettings, playerOptions)
imaPlayer.addEventListener('AdContentPauseRequested', () => {
isAdPlaying = true
$container.style.display = 'block'
})
imaPlayer.addEventListener('AdContentResumeRequested', () => {
isAdPlaying = false
$container.style.display = 'none'
})
imaPlayer.addEventListener('AdStarted', () => {
isAdPlaying = true
$container.style.display = 'block'
})
imaPlayer.addEventListener('AdError', (event) => {
console.error('VAST Ad Error:', event.detail)
isAdPlaying = false
$container.style.display = 'none'
})
return imaPlayer
}
function destroyPlayer() {
if (imaPlayer?.destroy)
imaPlayer.destroy()
if ($container?.parentNode)
$container.parentNode.removeChild($container)
$container = null
imaPlayer = null
}
function playUrl(url, config = {}) {
if (isAdPlaying)
return
if (!imaPlayer)
initPlayer()
const request = new ima.AdsRequest()
request.adTagUrl = url
for (const key in config) {
request[key] = config[key]
}
imaPlayer.playAds(request)
}
function playRes(res, config = {}) {
if (isAdPlaying)
return
if (!imaPlayer)
initPlayer()
const request = new ima.AdsRequest()
request.adsResponse = res
for (const key in config) {
request[key] = config[key]
}
imaPlayer.playAds(request)
}
if (typeof callback === 'function') {
await callback({
art,
playUrl,
playRes,
init: initPlayer,
ima,
adsRenderingSettings,
playerOptions,
get imaPlayer() {
return imaPlayer
},
get container() {
return $container
},
})
}
return {
name: 'artplayerPluginVast',
destroy: destroyPlayer,
}
}
}
@@ -0,0 +1,48 @@
import type Artplayer from 'artplayer'
import type { Callback, Result } from './types'
import { loadImaSdk } from './sdk'
import { createSession } from './session'
export default function artplayerPluginVast(callback?: Callback) {
return async (art: Artplayer): Promise<Result> => {
let disposed = false
let session: ReturnType<typeof createSession> | undefined
const closed = () => disposed || art.isDestroy
const result: Result = { name: 'artplayerPluginVast', destroy: () => session?.destroy() }
function dispose(): void {
if (disposed)
return
disposed = true
try {
art.off('destroy', dispose)
}
finally { session?.destroy() }
}
if (closed())
return result
art.on('destroy', dispose)
try {
if (closed())
return result
const ima = await loadImaSdk()
if (closed())
return result
const utils = (art.constructor as typeof Artplayer).utils
session = createSession(art, ima, utils, closed)
if (closed()) {
dispose()
return result
}
if (typeof callback === 'function')
await callback(session.context)
return result
}
catch (error) {
try {
dispose()
}
catch (cleanupError) { console.error('VAST cleanup error:', cleanupError) }
throw error
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { ImaSdk } from '@alugha/ima'
import type { RequestConfig } from './types'
export { loadImaSdk, Player, PlayerOptions } from '@glomex/vast-ima-player'
export function createRequest(ima: ImaSdk, field: 'adTagUrl' | 'adsResponse', value: string, config: RequestConfig) {
const request = new ima.AdsRequest()
request[field] = value
// Preserve the historical SDK extension surface, including inherited config fields.
const fields = request as unknown as Record<string, unknown>
for (const key in config)
fields[key] = config[key]
return request
}
@@ -0,0 +1,164 @@
import type { ImaSdk } from '@alugha/ima'
import type Artplayer from 'artplayer'
import type { Context, RequestConfig, Utilities } from './types'
import { createRequest, Player, PlayerOptions } from './sdk'
import { createContainer } from './view'
interface Owner {
container: HTMLDivElement
player: Player | null
listeners: [string, EventListener][]
playing: boolean
released: boolean
}
export function createSession(art: Artplayer, ima: ImaSdk, utils: Utilities, closed: () => boolean) {
const { $video, $player } = art.template
const adsRenderingSettings = new ima.AdsRenderingSettings()
adsRenderingSettings.restoreCustomPlaybackStateOnAdBreakComplete = true
adsRenderingSettings.enablePreloading = true
const playerOptions = new PlayerOptions()
let current: Owner | null = null
let initializing = false
let releasing = 0
const live = (owner: Owner) => current === owner && !owner.released && !closed()
function release(owner: Owner): void {
if (current === owner)
current = null
owner.released = true
owner.playing = false
const player = owner.player
owner.player = null
const listeners = owner.listeners.splice(0)
let failed = false
let failure: unknown
const attempt = (cleanup: () => void) => {
try {
cleanup()
}
catch (error) {
if (!failed) {
failed = true
failure = error
}
else {
console.error('VAST cleanup error:', error)
}
}
}
releasing++
try {
if (player) {
for (const [name, callback] of listeners)
attempt(() => player.removeEventListener(name, callback))
attempt(() => player.destroy())
}
attempt(() => {
if (owner.container.parentNode)
owner.container.parentNode.removeChild(owner.container)
})
}
finally {
releasing--
}
if (failed)
throw failure
}
function init(): Player | null {
if (closed() || releasing)
return null
if (initializing)
throw new Error('VAST initialization is already in progress')
if (current?.player)
return current.player
initializing = true
let owner: Owner | undefined
try {
owner = { container: createContainer(utils), player: null, listeners: [], playing: false, released: false }
current = owner
if (!live(owner)) {
release(owner)
return null
}
$player.appendChild(owner.container)
if (!live(owner)) {
release(owner)
return null
}
owner.player = new Player(ima, $video, owner.container, adsRenderingSettings, playerOptions)
if (!live(owner)) {
release(owner)
return null
}
const allocated = owner
for (const [name, playing] of [
['AdContentPauseRequested', true],
['AdContentResumeRequested', false],
['AdStarted', true],
['AdError', false],
] as const) {
const callback: EventListener = (event) => {
if (!live(allocated))
return
if (name === 'AdError')
console.error('VAST Ad Error:', (event as CustomEvent<unknown>).detail)
allocated.playing = playing
allocated.container.style.display = playing ? 'block' : 'none'
}
owner.listeners.push([name, callback])
owner.player.addEventListener(name, callback)
if (!live(owner)) {
release(owner)
return null
}
}
return owner.player
}
catch (error) {
if (owner) {
try {
release(owner)
}
catch (cleanupError) { console.error('VAST cleanup error:', cleanupError) }
}
throw error
}
finally {
initializing = false
}
}
function play(field: 'adTagUrl' | 'adsResponse', value: string, config: RequestConfig): void {
if (closed() || current?.playing)
return
const player = init()
const owner = current
if (!player || !owner)
return
const request = createRequest(ima, field, value, config)
// Request constructors and config accessors may synchronously destroy or replace the session.
if (live(owner) && owner.player === player)
player.playAds(request)
}
function destroy(): void {
if (current)
release(current)
}
const context: Context = {
art,
playUrl: (url, config = {}) => play('adTagUrl', url, config),
playRes: (response, config = {}) => play('adsResponse', response, config),
init,
ima,
adsRenderingSettings,
playerOptions,
get imaPlayer() { return current?.player || null },
get container() { return current?.container || null },
}
return { context, destroy }
}
@@ -0,0 +1,24 @@
import type { google, ImaSdk } from '@alugha/ima'
import type { Player, PlayerOptions } from '@glomex/vast-ima-player'
import type Artplayer from 'artplayer'
export type Utilities = Pick<typeof Artplayer.utils, 'createElement' | 'setStyles'>
export type RequestConfig = Record<string, unknown>
export interface Context {
art: Artplayer
playUrl: (url: string, config?: RequestConfig) => void
playRes: (response: string, config?: RequestConfig) => void
init: () => Player | null
ima: ImaSdk
adsRenderingSettings: google.ima.AdsRenderingSettings
playerOptions: PlayerOptions
readonly imaPlayer: Player | null
readonly container: HTMLDivElement | null
}
export type Callback = (context: Context) => unknown
export interface Result {
name: 'artplayerPluginVast'
destroy: () => void
}
@@ -0,0 +1,19 @@
import type { Utilities } from './types'
let nextId = 0
export function createContainer(utils: Utilities): HTMLDivElement {
const container = utils.createElement('div')
container.id = `art-vast-${Date.now()}-${++nextId}`
utils.setStyles(container, {
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
zIndex: '150',
backgroundColor: 'black',
display: 'none',
pointerEvents: 'auto',
})
return container
}