refactor(ads): [PKG-ADS-03] split TypeScript lifecycle and preserve legacy behavior

This commit is contained in:
Harvey Zhao committed 2026-09-12 07:55:55 +08:00
1 parent 069ad4aae1
commit 39e2d4cdfb
37 files changed
+3083 -788

No files matched your search

@@ -0,0 +1,55 @@
import type { Cleanup } from './types'
export function createCountdown(total: () => number, render: (seconds: number) => void, complete: Cleanup) {
let elapsed = 0
let ready = false
let running = false
let closed = false
let timer: ReturnType<typeof setTimeout> | undefined
function schedule(): void {
if (!ready || !running || closed || timer !== undefined)
return
timer = setTimeout(() => {
timer = undefined
if (closed || !running)
return
elapsed += 1
render(elapsed)
if (closed)
return
if (elapsed >= total())
complete()
else
schedule()
}, 1000)
}
function pause(): void {
running = false
if (timer !== undefined)
clearTimeout(timer)
timer = undefined
}
return {
start() {
if (ready || closed)
return
ready = true
running = true
schedule()
},
play() {
if (!ready || closed)
return
running = true
schedule()
},
pause,
stop() {
closed = true
pause()
},
}
}
-294
View File
@@ -1,294 +0,0 @@
import style from './style.less?inline'
function checkVersion(art) {
const {
version,
utils: { errorHandle },
} = art.constructor
const arr = version.split('.').map(Number)
const major = arr[0]
const minor = arr[1] / 100
errorHandle(
major + minor >= 5,
`Artplayer.js@${version} is not compatible the artplayerPluginAds@${artplayerPluginAds.version}. Please update it to version Artplayer.js@5.x.x`,
)
}
export default function artplayerPluginAds(option) {
return (art) => {
checkVersion(art)
const {
template: { $player },
icons: { volume, volumeClose, fullscreenOn, fullscreenOff, loading },
constructor: {
validator,
utils: { query, append, setStyle },
},
} = art
option = validator(
{
html: '',
video: '',
url: '',
playDuration: 5,
totalDuration: 10,
muted: false,
i18n: {
close: '关闭广告',
countdown: '%s秒',
detail: '查看详情',
canBeClosed: '%s秒后可关闭广告',
},
...option,
},
{
html: '?string',
video: '?string',
url: '?string',
playDuration: 'number',
totalDuration: 'number',
muted: '?boolean',
i18n: {
close: 'string',
countdown: 'string',
detail: 'string',
canBeClosed: 'string',
},
},
)
let $ads = null
let $timer = null
let $close = null
let $countdown = null
let $control = null
let $loading = null
let time = 0
let timer = null
let isEnd = false
let isInit = false
let isCanClose = false
function getI18n(val, str) {
return str.replace('%s', val)
}
function skip() {
isEnd = true
art.play()
if (option.video)
$ads.pause()
setStyle(art.template.$ads, 'display', 'none')
art.emit('artplayerPluginAds:skip', option)
}
function play() {
if (isEnd)
return
timer = setTimeout(() => {
time += 1
const playDuration = option.playDuration - time
if (playDuration >= 1) {
$close.innerHTML = getI18n(playDuration, option.i18n.canBeClosed)
}
else {
$close.innerHTML = option.i18n.close
if (!isCanClose) {
isCanClose = true
}
}
$countdown.innerHTML = getI18n(option.totalDuration - time, option.i18n.countdown)
if (time >= option.totalDuration) {
skip()
}
else {
play()
}
}, 1000)
}
function pause() {
if (isEnd)
return
clearTimeout(timer)
}
function show() {
art.template.$ads = append($player, '<div class="artplayer-plugin-ads"></div>')
$ads = append(
art.template.$ads,
option.video
? `<video class="artplayer-plugin-ads-video" src="${option.video}" loop playsInline></video>`
: `<div class="artplayer-plugin-ads-html">${option.html}</div>`,
)
$loading = append(art.template.$ads, '<div class="artplayer-plugin-ads-loading"></div>')
append($loading, loading)
$timer = append(
art.template.$ads,
`<div class="artplayer-plugin-ads-timer">
<div class="artplayer-plugin-ads-close">${
option.playDuration <= 0
? option.i18n.close
: getI18n(option.playDuration, option.i18n.canBeClosed)
}</div>
<div class="artplayer-plugin-ads-countdown">${getI18n(
option.totalDuration,
option.i18n.countdown,
)}</div>
</div>`,
)
$close = query('.artplayer-plugin-ads-close', $timer)
$countdown = query('.artplayer-plugin-ads-countdown', $timer)
if (option.playDuration >= option.totalDuration) {
setStyle($close, 'display', 'none')
}
art.proxy($close, 'click', () => {
if (isCanClose) {
skip()
}
})
$control = append(
art.template.$ads,
`<div class="artplayer-plugin-ads-control">
<div class="artplayer-plugin-ads-detail">${option.i18n.detail}</div>
<div class="artplayer-plugin-ads-muted"></div>
<div class="artplayer-plugin-ads-fullscreen"></div>
</div>`,
)
const $detail = query('.artplayer-plugin-ads-detail', $control)
const $muted = query('.artplayer-plugin-ads-muted', $control)
const $fullscreen = query('.artplayer-plugin-ads-fullscreen', $control)
if (option.url) {
art.proxy($detail, 'click', () => {
window.open(option.url)
art.emit('artplayerPluginAds:click', option)
})
}
else {
setStyle($detail, 'display', 'none')
}
if (option.video) {
const $volume = append($muted, volume)
const $volumeClose = append($muted, volumeClose)
setStyle($volumeClose, 'display', 'none')
// If the ad was set to muted initially, match the icon
if (option.muted) {
$ads.muted = true
setStyle($volume, 'display', 'none')
setStyle($volumeClose, 'display', 'inline-flex')
}
art.proxy($muted, 'click', () => {
$ads.muted = !$ads.muted
if ($ads.muted) {
setStyle($volume, 'display', 'none')
setStyle($volumeClose, 'display', 'inline-flex')
}
else {
setStyle($volume, 'display', 'inline-flex')
setStyle($volumeClose, 'display', 'none')
}
})
}
else {
setStyle($muted, 'display', 'none')
}
const $fullscreenOn = append($fullscreen, fullscreenOn)
const $fullscreenOff = append($fullscreen, fullscreenOff)
setStyle($fullscreenOff, 'display', 'none')
art.proxy($fullscreen, 'click', () => {
art.fullscreen = !art.fullscreen
if (art.fullscreen) {
setStyle($fullscreenOn, 'display', 'inline-flex')
setStyle($fullscreenOff, 'display', 'none')
}
else {
setStyle($fullscreenOn, 'display', 'none')
setStyle($fullscreenOff, 'display', 'inline-flex')
}
})
art.proxy($ads, 'click', () => {
if (option.url)
window.open(option.url)
art.emit('artplayerPluginAds:click', option)
})
}
function init() {
if (isInit)
return
isInit = true
show()
art.pause()
if (option.video) {
art.proxy($ads, 'error', skip)
art.proxy($ads, 'loadedmetadata', () => {
play()
$ads.play()
setStyle($timer, 'display', 'flex')
setStyle($control, 'display', 'flex')
setStyle($loading, 'display', 'none')
})
}
else {
play()
setStyle($timer, 'display', 'flex')
setStyle($control, 'display', 'flex')
setStyle($loading, 'display', 'none')
}
art.on('document:visibilitychange', () => {
if (document.hidden) {
pause()
}
else {
play()
}
})
}
art.on('ready', () => {
art.once('play', init)
art.once('video:playing', init)
})
return {
name: 'artplayerPluginAds',
skip,
pause,
play,
}
}
}
if (typeof document !== 'undefined') {
if (!document.getElementById('artplayer-plugin-ads')) {
const $style = document.createElement('style')
$style.id = 'artplayer-plugin-ads'
$style.textContent = style
document.head.appendChild($style)
}
}
@@ -0,0 +1,25 @@
import type Artplayer from 'artplayer'
import type { Input, Result } from './types'
import { normalizeOptions } from './options'
import { createSession } from './session'
import style from './style.less?inline'
export default function artplayerPluginAds(input?: Input) {
return (art: Artplayer): Result => {
// Legacy core declarations type instance.constructor as Function. Check the
// actual capabilities before using its existing static validator/utilities.
const constructor = art.constructor as typeof Artplayer
if (typeof constructor.validator !== 'function'
|| !['append', 'query', 'setStyle'].every(name => typeof Reflect.get(constructor.utils || {}, name) === 'function')) { throw new Error('Artplayer Ads requires the core validator and DOM utilities') }
const option = normalizeOptions(input, constructor.validator)
const { volume, volumeClose, fullscreenOn, fullscreenOff, loading } = art.icons
return createSession(art, option, { volume, volumeClose, fullscreenOn, fullscreenOff, loading }, constructor.utils)
}
}
if (typeof document !== 'undefined' && !document.getElementById('artplayer-plugin-ads')) {
const element = document.createElement('style')
element.id = 'artplayer-plugin-ads'
element.textContent = style
document.head.appendChild(element)
}
@@ -0,0 +1,28 @@
import type Artplayer from 'artplayer'
import type { Input, Options } from './types'
export function normalizeOptions(input: Input | undefined, validate: typeof Artplayer.validator): Options {
return validate({
html: '',
video: '',
url: '',
playDuration: 5,
totalDuration: 10,
muted: false,
i18n: {
close: '关闭广告',
countdown: '%s秒',
detail: '查看详情',
canBeClosed: '%s秒后可关闭广告',
},
...input,
}, {
html: '?string',
video: '?string',
url: '?string',
playDuration: 'number',
totalDuration: 'number',
muted: '?boolean',
i18n: { close: 'string', countdown: 'string', detail: 'string', canBeClosed: 'string' },
})
}
@@ -0,0 +1,58 @@
import type { Cleanup, Host } from './types'
export function report(error: unknown): void {
console.warn('Artplayer Ads:', error)
}
export function createResources(host: Pick<Host, 'on' | 'off'>) {
const cleaners: Cleanup[] = []
let closed = false
function install(add: Cleanup, remove: Cleanup): void {
if (closed)
return
cleaners.push(remove)
try {
add()
}
finally {
// A custom host/target can destroy the owner while registration is executing.
if (closed)
remove()
}
}
return {
on(name: string, callback: Cleanup) {
const listener = () => {
if (!closed)
callback()
}
install(() => {
host.on(name, listener)
}, () => {
host.off(name, listener)
})
},
dom(target: EventTarget, name: string, callback: Cleanup) {
const listener = () => {
if (!closed)
callback()
}
install(() => target.addEventListener(name, listener), () => target.removeEventListener(name, listener))
},
dispose() {
if (closed)
return
closed = true
for (const clean of cleaners.splice(0).reverse()) {
try {
clean()
}
catch (error) { report(error) }
}
},
}
}
export type Resources = ReturnType<typeof createResources>
@@ -0,0 +1,182 @@
import type { Host, Icons, Options, Result, Utilities } from './types'
import type { View } from './view'
import { createCountdown } from './countdown'
import { createResources, report } from './resources'
import { createView } from './view'
export function createSession(art: Host, option: Options, icons: Icons, utils: Utilities): Result {
const events = createResources(art)
const lifetime = createResources(art)
const template = art.template as Host['template'] & { $ads?: HTMLElement }
let state: 'waiting' | 'active' | 'ended' | 'destroyed' = 'waiting'
let initialized = false
let armed = false
let mediaReady = false
let root: HTMLElement | undefined
let view: View | undefined
const clock = createCountdown(() => option.totalDuration, time => view?.render(time), skip)
const active = () => state === 'active' && !art.isDestroy
const destroyed = () => state === 'destroyed' || art.isDestroy
function pauseVideo(video: HTMLVideoElement | null | undefined): void {
try {
video?.pause()
}
catch (error) { report(error) }
}
function requestPlay(start: () => unknown, rejected: (error: unknown) => void, late?: () => void): void {
try {
Promise.resolve(start()).then(() => {
if (!active())
late?.()
}, rejected).catch(report)
}
catch (error) { rejected(error) }
}
function skip(): void {
if (state === 'ended' || state === 'destroyed' || art.isDestroy)
return
state = 'ended'
clock.stop()
events.dispose()
if (initialized) {
requestPlay(() => art.play(), (error) => {
if (state !== 'destroyed')
report(error)
})
if (destroyed())
return
pauseVideo(view?.video)
if (destroyed())
return
view?.hide()
}
if (!destroyed())
art.emit('artplayerPluginAds:skip', option)
}
function disposeView(): void {
const video = view?.video
pauseVideo(video)
if (video) {
try {
video.removeAttribute('src')
video.load()
}
catch (error) { report(error) }
}
try {
root?.remove()
}
catch (error) { report(error) }
finally {
if (template.$ads === root)
delete template.$ads
}
}
function destroy(): void {
if (state === 'destroyed')
return
state = 'destroyed'
clock.stop()
events.dispose()
lifetime.dispose()
disposeView()
}
function init(): void {
if (state !== 'waiting' || art.isDestroy)
return
state = 'active'
initialized = true
try {
view = createView(template.$player, icons, option, utils, (node) => {
root = node
template.$ads = node
})
if (!active()) {
disposeView()
return
}
art.pause()
if (!active())
return
const current = view
const document = root!.ownerDocument
current.fullscreen(art.fullscreen)
current.bind(events, {
skip,
click() {
if (option.url)
(document.defaultView || window).open(option.url)
if (active())
art.emit('artplayerPluginAds:click', option)
},
fullscreen() {
art.fullscreen = !art.fullscreen
if (active())
current.fullscreen(art.fullscreen)
},
})
events.on('fullscreen', () => current.fullscreen(art.fullscreen))
const visibility = () => document.hidden ? clock.pause() : clock.play()
events.dom(document, 'visibilitychange', visibility)
// Support the newer core event bridge as well; the clock deduplicates resume.
events.on('document:visibilitychange', visibility)
const ready = () => {
if (!active() || mediaReady)
return
mediaReady = true
clock.start()
if (document.hidden)
clock.pause()
if (current.video) {
const video = current.video
requestPlay(() => video.play(), (error) => {
if (active()) {
report(error)
skip()
}
}, () => pauseVideo(video))
}
if (active())
current.ready()
}
if (current.video) {
events.dom(current.video, 'error', skip)
events.dom(current.video, 'loadedmetadata', ready)
// Observe readiness/failure before initiating a potentially cached request.
current.video.src = option.video!
}
else {
ready()
}
}
catch (error) {
destroy()
throw error
}
}
try {
lifetime.on('destroy', destroy)
events.on('ready', () => {
if (armed || state !== 'waiting')
return
armed = true
events.on('play', init)
events.on('video:playing', init)
})
if (art.isDestroy)
destroy()
}
catch (error) {
destroy()
throw error
}
return { name: 'artplayerPluginAds', skip, pause: clock.pause, play: clock.play }
}
@@ -0,0 +1,31 @@
import type Artplayer from 'artplayer'
export interface Translations {
close: string
countdown: string
detail: string
canBeClosed: string
}
export interface Options {
html?: string
video?: string
url?: string
playDuration: number
totalDuration: number
muted?: boolean
i18n: Translations
}
export type Input = Partial<Options>
export type Host = Pick<Artplayer, 'on' | 'off' | 'emit' | 'play' | 'pause' | 'template' | 'icons' | 'fullscreen' | 'isDestroy'>
export type Utilities = Pick<typeof Artplayer.utils, 'append' | 'query' | 'setStyle'>
export type Icons = Pick<Artplayer['icons'], 'volume' | 'volumeClose' | 'fullscreenOn' | 'fullscreenOff' | 'loading'>
export type Cleanup = () => void
export interface Result {
name: 'artplayerPluginAds'
skip: Cleanup
pause: Cleanup
play: Cleanup
}
+95
View File
@@ -0,0 +1,95 @@
import type { Resources } from './resources'
import type { Cleanup, Icons, Options, Utilities } from './types'
const translate = (value: number, text: string) => text.replace('%s', String(value))
export function createView(parent: HTMLElement, icons: Icons, option: Options, utils: Utilities, own: (root: HTMLElement) => void) {
const { append, query, setStyle } = utils
// The private markup below fixes the element kind; public append/query retain their broad types.
const root = append(parent, '<div class="artplayer-plugin-ads"></div>') as HTMLElement
own(root)
const content = append(root, option.video
? '<video class="artplayer-plugin-ads-video" loop playsInline></video>'
: `<div class="artplayer-plugin-ads-html">${option.html}</div>`) as HTMLElement
const video = option.video ? content as HTMLVideoElement : null
const loading = append(root, '<div class="artplayer-plugin-ads-loading"></div>') as HTMLElement
append(loading, icons.loading)
const timer = append(root, `<div class="artplayer-plugin-ads-timer">
<div class="artplayer-plugin-ads-close"></div>
<div class="artplayer-plugin-ads-countdown"></div>
</div>`) as HTMLElement
const close = query('.artplayer-plugin-ads-close', timer) as HTMLElement
const countdown = query('.artplayer-plugin-ads-countdown', timer) as HTMLElement
const control = append(root, `<div class="artplayer-plugin-ads-control">
<div class="artplayer-plugin-ads-detail">${option.i18n.detail}</div>
<div class="artplayer-plugin-ads-muted"></div>
<div class="artplayer-plugin-ads-fullscreen"></div>
</div>`) as HTMLElement
const detail = query('.artplayer-plugin-ads-detail', control) as HTMLElement
const muted = query('.artplayer-plugin-ads-muted', control) as HTMLElement
const fullscreen = query('.artplayer-plugin-ads-fullscreen', control) as HTMLElement
let canClose = option.playDuration <= 0
if (option.playDuration >= option.totalDuration)
setStyle(close, 'display', 'none')
if (!option.url)
setStyle(detail, 'display', 'none')
if (video) {
append(muted, icons.volume)
append(muted, icons.volumeClose)
video.muted = Boolean(option.muted)
syncMuted()
}
else {
setStyle(muted, 'display', 'none')
}
append(fullscreen, icons.fullscreenOn)
append(fullscreen, icons.fullscreenOff)
function syncMuted(): void {
setStyle(icons.volume, 'display', video?.muted ? 'none' : 'inline-flex')
setStyle(icons.volumeClose, 'display', video?.muted ? 'inline-flex' : 'none')
}
function render(elapsed: number): void {
const remaining = option.playDuration - elapsed
canClose = elapsed === 0 ? option.playDuration <= 0 : remaining < 1 || Number.isNaN(remaining)
close.innerHTML = canClose ? option.i18n.close : translate(remaining, option.i18n.canBeClosed)
countdown.innerHTML = translate(option.totalDuration - elapsed, option.i18n.countdown)
}
render(0)
return {
root,
video,
render,
ready() {
setStyle(timer, 'display', 'flex')
setStyle(control, 'display', 'flex')
setStyle(loading, 'display', 'none')
},
hide() { setStyle(root, 'display', 'none') },
fullscreen(active: boolean) {
setStyle(icons.fullscreenOn, 'display', active ? 'none' : 'inline-flex')
setStyle(icons.fullscreenOff, 'display', active ? 'inline-flex' : 'none')
},
bind(events: Resources, callbacks: { skip: Cleanup, click: Cleanup, fullscreen: Cleanup }) {
events.dom(close, 'click', () => {
if (canClose)
callbacks.skip()
})
events.dom(content, 'click', callbacks.click)
if (option.url)
events.dom(detail, 'click', callbacks.click)
if (video) {
events.dom(muted, 'click', () => {
video.muted = !video.muted
syncMuted()
})
}
events.dom(fullscreen, 'click', callbacks.fullscreen)
},
}
}
export type View = ReturnType<typeof createView>