refactor(core): CORE-14 split typed settings and scope tree lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 06:49:04 +08:00
1 parent 697817eceb
commit 64d1939daa
51 files changed
+28144 -1519

No files matched your search

@@ -0,0 +1,22 @@
import type ResourceScope from '../lifecycle/scope'
const pauses = new WeakMap<ResourceScope, number>()
export function settingScopeActive(scope: ResourceScope): boolean {
return !scope.closed && !pauses.get(scope)
}
export function pauseSettingScope(scope: ResourceScope): () => void {
pauses.set(scope, (pauses.get(scope) || 0) + 1)
let paused = true
return () => {
if (!paused)
return
paused = false
const count = (pauses.get(scope) || 1) - 1
if (count)
pauses.set(scope, count)
else
pauses.delete(scope)
}
}
@@ -1,11 +1,14 @@
export default function aspectRatio(art) {
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function aspectRatio(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, ASPECT_RATIO },
} = art
function getI18n(value) {
function getI18n(value: string) {
return value === 'default' ? i18n.get('Default') : value
}
@@ -29,12 +32,12 @@ export default function aspectRatio(art) {
}
}),
onSelect(item) {
art.aspectRatio = item.value
art.aspectRatio = item.value as string
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('aspectRatio', () => update())
subscribeSetting(art, item, 'aspectRatio', () => update())
},
}
}
@@ -0,0 +1,40 @@
import type { SettingItem } from './types'
type CaptureState = () => () => void
const states = new WeakMap<SettingItem, CaptureState>()
export function rememberSettingState(item: SettingItem, capture?: CaptureState): void {
if (capture)
states.set(item, capture)
else
states.delete(item)
}
// Capture descriptors without invoking user getters while preparing a render.
export function captureSettingItem(item: SettingItem): () => void {
const descriptors = new Map(Reflect.ownKeys(item).map(key => [key, Object.getOwnPropertyDescriptor(item, key)!]))
const nodes: { node: Node, parent: Node | null, next: Node | null }[] = []
const state = states.get(item)
const restoreState = state?.()
for (const key of ['html', 'icon', 'tooltip']) {
const value: unknown = descriptors.get(key)?.value
if (value instanceof Node)
nodes.push({ node: value, parent: value.parentNode, next: value.nextSibling })
}
return () => {
for (const key of Reflect.ownKeys(item)) {
if (!descriptors.has(key) && Object.getOwnPropertyDescriptor(item, key)?.configurable)
Reflect.deleteProperty(item, key)
}
for (const [key, descriptor] of descriptors)
Object.defineProperty(item, key, descriptor)
restoreState?.()
rememberSettingState(item, state)
for (const { node, parent, next } of nodes) {
if (parent)
parent.insertBefore(node, next?.parentNode === parent ? next : null)
else
node.parentNode?.removeChild(node)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { SettingEvents, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function installSettingEvents(setting: SettingManager): void {
const { art } = setting
const scope = getScope(art).child()
const on = <Name extends keyof SettingEvents>(name: Name, callback: (...args: SettingEvents[Name]) => unknown) => {
if (scope.closed || isClosing(art))
return
const guarded = (...args: SettingEvents[Name]) => {
if (!scope.closed && !isClosing(art))
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
on('blur', () => {
if (setting.show) {
setting.show = false
setting.render()
}
})
on('focus', (event) => {
const isControl = includeFromEvent(event, art.controls.setting)
const isSetting = includeFromEvent(event, setting.$parent)
if (setting.show && !isControl && !isSetting) {
setting.show = false
setting.render()
}
})
on('resize', () => setting.resize())
const resize = () => {
if (!scope.closed && !isClosing(art))
setting.resize()
}
const transitioned = art.proxy(setting.$parent, 'transitionend', (event) => {
if (event.target === setting.$parent && (event as TransitionEvent).propertyName === 'bottom')
resize()
})
scope.add(() => {
art.events.remove(transitioned)
})
const { $player, $controls } = art.template
if (!scope.closed && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(resize)
observer.observe($player)
observer.observe($controls)
scope.add(() => {
observer.disconnect()
})
}
if (!scope.closed && typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(resize)
if (typeof ResizeObserver === 'undefined')
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
// Controls can publish their measured height after our ResizeObserver callback.
observer.observe($player, { attributes: true, attributeFilter: ['class', 'style'] })
scope.add(() => {
observer.disconnect()
})
}
}
@@ -1,13 +1,15 @@
import type { SettingHost, SettingItem } from './types'
import { capitalize } from '../utils'
import { subscribeSetting } from './resources'
export default function flip(art) {
export default function flip(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, FLIP },
} = art
function getI18n(value) {
function getI18n(value: string) {
return i18n.get(capitalize(value))
}
@@ -31,12 +33,12 @@ export default function flip(art) {
}
}),
onSelect(item) {
art.flip = item.value
art.flip = item.value as string
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('flip', () => update())
subscribeSetting(art, item, 'flip', () => update())
},
}
}
-558
View File
@@ -1,558 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import {
addClass,
append,
createElement,
def,
errorHandle,
getRect,
has,
includeFromEvent,
inverseClass,
isMobile,
remove,
replaceElement,
setStyle,
} from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import flip from './flip'
import playbackRate from './playbackRate'
import subtitleOffset from './subtitleOffset'
export default class Setting extends Component {
constructor(art) {
super(art)
const {
option,
controls,
template: { $setting },
} = art
this.name = 'setting'
this.$parent = $setting
this.id = 0
this.active = null
this.cache = new Map()
this.option = [...this.builtin, ...option.settings]
if (option.setting) {
this.format()
this.render()
art.on('blur', () => {
if (this.show) {
this.show = false
this.render()
}
})
art.on('focus', (event) => {
const isControl = includeFromEvent(event, controls.setting)
const isSetting = includeFromEvent(event, this.$parent)
if (this.show && !isControl && !isSetting) {
this.show = false
this.render()
}
})
art.on('resize', () => this.resize())
}
}
get builtin() {
const result = []
const { option } = this.art
if (option.playbackRate) {
result.push(playbackRate(this.art))
}
if (option.aspectRatio) {
result.push(aspectRatio(this.art))
}
if (option.flip) {
result.push(flip(this.art))
}
if (option.subtitleOffset) {
result.push(subtitleOffset(this.art))
}
return result
}
traverse(callback, option = this.option) {
for (let index = 0; index < option.length; index++) {
const item = option[index]
callback(item)
if (item.selector?.length) {
this.traverse(callback, item.selector)
}
}
}
check(target) {
if (!target) {
return
}
target.$parent.tooltip = target.html
this.traverse((item) => {
item.default = item === target
if (item.default && item.$item) {
inverseClass(item.$item, 'art-current')
}
}, target.$option)
this.render(target.$parents)
}
format(option = this.option, parent, parents, names = []) {
for (let index = 0; index < option.length; index++) {
const item = option[index]
if (item?.name) {
errorHandle(!names.includes(item.name), `The [${item.name}] already exists in [setting]`)
names.push(item.name)
}
else {
item.name = `setting-${this.id++}`
}
if (!item.$formatted) {
def(item, '$parent', {
get: () => parent,
})
def(item, '$parents', {
get: () => parents,
})
def(item, '$option', {
get: () => option,
})
const $events = []
def(item, '$events', {
get: () => $events,
})
def(item, '$formatted', {
get: () => true,
})
}
this.format(item.selector || [], item, option, names)
}
this.option = option
}
find(name = '') {
let result = null
this.traverse((item) => {
if (item.name === name) {
result = item
}
})
return result
}
resize() {
const {
controls,
constructor: { SETTING_WIDTH, SETTING_ITEM_HEIGHT },
template: { $player, $setting },
} = this.art
if (controls.setting && this.show) {
const settingWidth = this.active[0]?.$parent?.width || SETTING_WIDTH
const { left: controlLeft, width: controlWidth } = getRect(controls.setting)
const { left: playerLeft, width: playerWidth } = getRect($player)
const settingLeft = controlLeft - playerLeft + controlWidth / 2 - settingWidth / 2
const settingHeight
= this.active === this.option
? this.active.length * SETTING_ITEM_HEIGHT
: (this.active.length + 1) * SETTING_ITEM_HEIGHT
setStyle($setting, 'height', `${settingHeight}px`)
setStyle($setting, 'width', `${settingWidth}px`)
if (this.art.isRotate || isMobile)
return
if (settingLeft + settingWidth > playerWidth) {
setStyle($setting, 'left', null)
setStyle($setting, 'right', null)
}
else {
setStyle($setting, 'left', `${settingLeft}px`)
setStyle($setting, 'right', 'auto')
}
}
}
inactivate(item) {
for (let index = 0; index < item.$events.length; index++) {
this.art.events.remove(item.$events[index])
}
item.$events.length = 0
}
remove(name) {
const item = this.find(name)
errorHandle(item, `Can't find [${name}] in the [setting]`)
const index = item.$option.indexOf(item)
item.$option.splice(index, 1)
this.inactivate(item)
if (item.$item)
remove(item.$item)
this.render()
}
update(target) {
const item = this.find(target.name)
if (item) {
this.inactivate(item)
Object.assign(item, target)
this.format()
this.createItem(item, true)
this.render()
return item
}
else {
return this.add(target)
}
}
add(item, option = this.option) {
option.push(item)
this.format()
this.createItem(item)
this.render()
return item
}
createHeader(item) {
if (!this.cache.has(item.$option))
return
const $panel = this.cache.get(item.$option)
const {
proxy,
icons: { arrowLeft },
constructor: { SETTING_ITEM_HEIGHT },
} = this.art
const $item = createElement('div')
setStyle($item, 'height', `${SETTING_ITEM_HEIGHT}px`)
addClass($item, 'art-setting-item')
addClass($item, 'art-setting-item-back')
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $icon = createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
append($left, $icon)
append($left, item.$parent.html)
const event = proxy($item, 'click', () => this.render(item.$parents))
item.$parent.$events.push(event)
append($panel, $item)
}
createItem(item, isUpdate = false) {
if (!this.cache.has(item.$option))
return
const $panel = this.cache.get(item.$option)
const oldItem = item.$item
let type = 'selector'
if (has(item, 'switch')) {
type = 'switch'
}
if (has(item, 'range')) {
type = 'range'
}
if (has(item, 'onClick')) {
type = 'button'
}
const { icons, proxy, constructor } = this.art
const $item = createElement('div')
addClass($item, 'art-setting-item')
setStyle($item, 'height', `${constructor.SETTING_ITEM_HEIGHT}px`)
$item.dataset.name = item.name || ''
$item.dataset.value = item.value || ''
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $right = append($item, '<div class="art-setting-item-right"></div>')
const $icon = createElement('div')
addClass($icon, 'art-setting-item-left-icon')
switch (type) {
case 'button':
case 'switch':
case 'range':
append($icon, item.icon || icons.config)
break
case 'selector':
if (item.selector?.length) {
append($icon, item.icon || icons.config)
}
else {
append($icon, icons.check)
}
break
default:
break
}
append($left, $icon)
def(item, '$icon', {
configurable: true,
get: () => $icon,
})
def(item, 'icon', {
configurable: true,
get() {
return $icon.innerHTML
},
set(value) {
$icon.innerHTML = ''
append($icon, value)
},
})
const $html = createElement('div')
addClass($html, 'art-setting-item-left-text')
append($html, item.html || '')
append($left, $html)
def(item, '$html', {
configurable: true,
get: () => $html,
})
def(item, 'html', {
configurable: true,
get() {
return $html.innerHTML
},
set(value) {
$html.innerHTML = ''
append($html, value)
},
})
const $tooltip = createElement('div')
addClass($tooltip, 'art-setting-item-right-tooltip')
append($tooltip, item.tooltip || '')
append($right, $tooltip)
def(item, '$tooltip', {
configurable: true,
get: () => $tooltip,
})
def(item, 'tooltip', {
configurable: true,
get() {
return $tooltip.innerHTML
},
set(value) {
$tooltip.innerHTML = ''
append($tooltip, value)
},
})
switch (type) {
case 'switch': {
const $switch = createElement('div')
addClass($switch, 'art-setting-item-right-icon')
const $switchOn = append($switch, icons.switchOn)
const $switchOff = append($switch, icons.switchOff)
setStyle(item.switch ? $switchOff : $switchOn, 'display', 'none')
append($right, $switch)
def(item, '$switch', {
configurable: true,
get: () => $switch,
})
let $switchValue = item.switch
def(item, 'switch', {
configurable: true,
get: () => $switchValue,
set(value) {
$switchValue = value
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
}
else {
setStyle($switchOff, 'display', null)
setStyle($switchOn, 'display', 'none')
}
},
})
break
}
case 'range':
{
const $state = createElement('div')
addClass($state, 'art-setting-item-right-icon')
const $range = append($state, '<input type="range">')
$range.value = item.range[0]
$range.min = item.range[1]
$range.max = item.range[2]
$range.step = item.range[3]
addClass($range, 'art-setting-range')
append($right, $state)
def(item, '$range', {
configurable: true,
get: () => $range,
})
let $rangeValue = [...item.range]
def(item, 'range', {
configurable: true,
get: () => $rangeValue,
set(value) {
$rangeValue = [...value]
$range.value = value[0]
$range.min = value[1]
$range.max = value[2]
$range.step = value[3]
},
})
}
break
case 'selector':
if (item.selector?.length) {
const $state = createElement('div')
addClass($state, 'art-setting-item-right-icon')
append($state, icons.arrowRight)
append($right, $state)
}
break
default:
break
}
switch (type) {
case 'switch': {
if (item.onSwitch) {
const event = proxy($item, 'click', async (event) => {
item.switch = await item.onSwitch.call(this.art, item, $item, event)
})
item.$events.push(event)
}
break
}
case 'range': {
if (item.$range) {
if (item.onRange) {
const event = proxy(item.$range, 'change', async (event) => {
item.range[0] = item.$range.valueAsNumber
item.tooltip = await item.onRange.call(this.art, item, $item, event)
})
item.$events.push(event)
}
if (item.onChange) {
const event = proxy(item.$range, 'input', async (event) => {
item.range[0] = item.$range.valueAsNumber
item.tooltip = await item.onChange.call(this.art, item, $item, event)
})
item.$events.push(event)
}
}
break
}
case 'selector':
{
const event = proxy($item, 'click', async (event) => {
if (item.selector?.length) {
this.render(item.selector)
}
else {
this.check(item)
if (item.$parent.onSelect) {
item.$parent.tooltip = await item.$parent.onSelect.call(this.art, item, $item, event)
}
}
})
item.$events.push(event)
if (item.default) {
addClass($item, 'art-current')
}
}
break
case 'button':
if (item.onClick) {
const event = proxy($item, 'click', async (event) => {
item.tooltip = await item.onClick.call(this.art, item, $item, event)
})
item.$events.push(event)
}
break
default:
break
}
def(item, '$item', {
configurable: true,
get: () => $item,
})
if (isUpdate) {
replaceElement($item, oldItem)
}
else {
append($panel, $item)
}
if (item.mounted) {
timeout(getScope(this.art), () => item.mounted.call(this.art, item.$item, item), 0)
}
}
render(option = this.option) {
this.active = option
if (this.cache.has(option)) {
const $panel = this.cache.get(option)
inverseClass($panel, 'art-current')
}
else {
const $panel = createElement('div')
this.cache.set(option, $panel)
addClass($panel, 'art-setting-panel')
append(this.$parent, $panel)
inverseClass($panel, 'art-current')
if (option[0]?.$parent) {
this.createHeader(option[0])
}
for (let index = 0; index < option.length; index++) {
this.createItem(option[index])
}
}
this.resize()
}
}
+185
View File
@@ -0,0 +1,185 @@
import type { SettingHost, SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { errorHandle } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import { installSettingEvents } from './events'
import flip from './flip'
import { resizeSetting } from './layout'
import { findItem, formatTree, registerTreeOwner, releaseTreeOwner, traverseTree } from './model'
import playbackRate from './playbackRate'
import { beginSettingAdd, cancelSettingAdd } from './registration'
import { createSettingHeader, createSettingItem, renderSetting } from './render'
import { releaseSettingTree } from './resources'
import { checkSetting } from './selection'
import subtitleOffset from './subtitleOffset'
import { cancelSettingUpdate, updateSetting } from './update'
// Keep the exact runtime inheritance; Setting overrides the registry operations and cache.
const SettingBase: new (art: SettingHost) => Pick<Component<SettingHost>, 'id' | 'art' | 'show' | 'toggle'> = Component
export default class Setting extends SettingBase implements SettingManager {
declare name: string
declare $parent: HTMLElement
declare cache: Map<SettingItem[], HTMLDivElement>
declare active: SettingItem[] | null
declare option: SettingItem[]
constructor(art: SettingHost) {
super(art)
const {
option,
template: { $setting },
} = art
this.name = 'setting'
this.$parent = $setting
this.id = 0
this.active = null
this.cache = new Map()
this.option = [...this.builtin, ...option.settings]
const scope = getScope(art)
registerTreeOwner(this, scope)
scope.add(() => {
releaseTreeOwner(this)
})
if (option.setting) {
this.format()
this.render()
installSettingEvents(this)
}
}
get builtin(): SettingItem[] {
const result: SettingItem[] = []
const { option } = this.art
if (option.playbackRate) {
result.push(playbackRate(this.art))
}
if (option.aspectRatio) {
result.push(aspectRatio(this.art))
}
if (option.flip) {
result.push(flip(this.art))
}
if (option.subtitleOffset) {
result.push(subtitleOffset(this.art))
}
return result
}
traverse(callback: (item: SettingItem) => void, option = this.option): void {
traverseTree(option, callback)
}
check(target?: SettingItem | null): void {
checkSetting(this, target)
}
format(option = this.option, parent?: SettingItem, parents?: SettingItem[], names: string[] = []): void {
this.option = formatTree(this, option, parent, parents, names)
}
find(name = ''): SettingItem | null {
return findItem(this.option, name)
}
resize(): void {
resizeSetting(this)
}
inactivate(item: SettingItem): void {
releaseSettingTree(this, item)
}
remove(name: string): void {
const item = this.find(name)!
errorHandle(item, `Can't find [${name}] in the [setting]`)
this.traverse((item) => {
cancelSettingAdd(item)
cancelSettingUpdate(item)
}, [item])
const index = item.$option!.indexOf(item)
item.$option!.splice(index, 1)
const element = item.$item
const failures: unknown[] = []
for (const cleanup of [() => this.inactivate(item), () => element?.remove(), () => this.render()]) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
if (failures.length)
throw new ResourceCleanupError(failures)
}
update(target: SettingItem): SettingItem {
return updateSetting(this, target)
}
add<Item extends SettingItem>(item: Item, option: SettingItem[] = this.option): Item {
if (isClosing(this.art))
return item
let registered = false
this.traverse((existing) => {
registered ||= existing === item
})
const index = option.length
const registration = registered ? undefined : beginSettingAdd(item)
let formatted = false
try {
option.push(item)
this.format()
formatted = true
this.createItem(item)
this.render()
}
catch (error) {
if (isClosing(this.art) || (registration && !registration.current()))
throw error
if (option[index] === item)
option.splice(index, 1)
if (!registered && formatted) {
try {
this.inactivate(item)
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
item.$item?.remove()
}
throw error
}
finally {
registration?.finish()
}
return item
}
createHeader(item: SettingItem): void {
createSettingHeader(this, item)
}
createItem(item: SettingItem, isUpdate = false): void {
createSettingItem(this, item, isUpdate)
}
render(option = this.option): void {
renderSetting(this, option)
}
}
+71
View File
@@ -0,0 +1,71 @@
import { isMobile } from '../utils/compatibility'
interface LayoutItem {
readonly $parent?: { width?: number }
}
export interface SettingLayout {
active: LayoutItem[] | null
option: LayoutItem[]
show: boolean
art: {
isRotate: boolean
controls: { setting?: HTMLElement }
constructor: { SETTING_WIDTH: number, SETTING_ITEM_HEIGHT: number }
template: { $player: HTMLElement, $setting: HTMLElement, $bottom: HTMLElement }
}
}
export interface LayoutInput {
containerWidth: number
containerHeight: number
requestedWidth: number
rows: number
rowHeight: number
controlCenter: number
bottom: number
padding: number
}
export function calculateSettingLayout(input: LayoutInput): { width: number, height: number, left: number } {
const { containerWidth, containerHeight, requestedWidth, rows, rowHeight, controlCenter, bottom, padding } = input
const inset = Math.min(Math.max(0, padding), Math.max(0, containerWidth / 2))
const width = Math.min(Math.max(0, requestedWidth), Math.max(0, containerWidth - 2 * inset))
const height = Math.min(Math.max(0, rows * rowHeight), Math.max(0, containerHeight - bottom - inset))
const left = Math.max(inset, Math.min(controlCenter - width / 2, containerWidth - inset - width))
return { width, height, left }
}
export function resizeSetting(setting: SettingLayout): void {
const { art, active } = setting
const { controls, constructor: { SETTING_WIDTH, SETTING_ITEM_HEIGHT }, template: { $player, $setting, $bottom } } = art
if (!controls.setting || !setting.show || !active)
return
const player = $player.getBoundingClientRect()
const control = controls.setting.getBoundingClientRect()
const scale = player.width / $player.offsetWidth || 1
const controlCenter = (control.left - player.left + control.width / 2) / scale - $player.clientLeft
const layout = calculateSettingLayout({
containerWidth: $player.clientWidth,
containerHeight: $player.clientHeight,
requestedWidth: active[0]?.$parent?.width || SETTING_WIDTH,
rows: active.length + (active === setting.option ? 0 : 1),
rowHeight: SETTING_ITEM_HEIGHT,
controlCenter,
bottom: Number.parseFloat(getComputedStyle($setting).bottom) || 0,
padding: Number.parseFloat(getComputedStyle($bottom).paddingLeft) || 0,
})
$setting.style.height = `${layout.height}px`
$setting.style.width = `${layout.width}px`
if (art.isRotate || isMobile)
return
if (controlCenter + layout.width / 2 > $player.clientWidth) {
// Preserve the existing CSS right-edge anchor when centering would overflow.
$setting.style.left = ''
$setting.style.right = ''
}
else {
$setting.style.left = `${layout.left}px`
$setting.style.right = 'auto'
}
}
+183
View File
@@ -0,0 +1,183 @@
import { errorHandle } from '../utils/error'
export interface TreeItem {
name?: string
selector?: TreeItem[]
readonly $parent?: TreeItem
readonly $parents?: TreeItem[]
readonly $option?: TreeItem[]
readonly $events?: (() => void)[]
readonly $formatted?: boolean
}
export interface TreeBinding {
parent?: TreeItem
parents?: TreeItem[]
option: TreeItem[]
events: (() => void)[]
owner: TreeOwnerState
}
interface TreeOwnerState {
active: boolean
root?: TreeItem[]
scope?: { readonly closed: boolean }
}
interface Placement {
item: TreeItem
parent?: TreeItem
parents?: TreeItem[]
option: TreeItem[]
name: string
generated: boolean
}
const bindings = new WeakMap<TreeItem, TreeBinding>()
const owners = new WeakMap<object, TreeOwnerState>()
const keys = ['$parent', '$parents', '$option', '$events', '$formatted'] as const
export function hasTreeBinding(item: TreeItem): boolean {
return bindings.has(item)
}
export function registerTreeOwner(owner: object, scope: { readonly closed: boolean }): void {
owners.set(owner, { active: true, scope })
}
export function releaseTreeOwner(owner: object): void {
const state = owners.get(owner)
if (state) {
state.active = false
state.root = undefined
state.scope = undefined
}
}
function activeBinding(item: TreeItem): boolean {
const visited = new Set<TreeItem>()
let current: TreeItem | undefined = item
while (current && !visited.has(current)) {
visited.add(current)
const binding: TreeBinding | undefined = bindings.get(current)
if (!binding || !binding.owner.active || binding.owner.scope?.closed || !binding.option.includes(current))
return false
if (!binding.parent)
return binding.owner.root === binding.option
if (bindings.get(binding.parent)?.owner !== binding.owner)
return false
current = binding.parent
}
return false
}
function canAssignName(item: TreeItem): boolean {
let target: object | null = item
while (target) {
const descriptor = Object.getOwnPropertyDescriptor(target, 'name')
if (descriptor) {
if ('set' in descriptor)
return typeof descriptor.set === 'function'
return Boolean(descriptor.writable && (target === item || Object.isExtensible(item)))
}
target = Object.getPrototypeOf(target)
}
return Object.isExtensible(item)
}
export function treeBinding(item: TreeItem): TreeBinding {
const binding = bindings.get(item)
if (!binding)
throw new Error('Setting item has not been formatted')
return binding
}
export function traverseTree(option: TreeItem[], callback: (item: TreeItem) => void): void {
for (let index = 0; index < option.length; index++) {
const item = option[index]!
callback(item)
if (item.selector?.length)
traverseTree(item.selector, callback)
}
}
export function findItem(option: TreeItem[], name = ''): TreeItem | null {
let result: TreeItem | null = null
traverseTree(option, (item) => {
if (item.name === name)
result = item
})
return result
}
// Validate the complete structure before installing any names or bindings.
export function formatTree<Item extends TreeItem>(owner: { id: number }, option: Item[], parent?: TreeItem, parents?: TreeItem[], names: string[] = []): Item[] {
const ownerState = owners.get(owner) || { active: true }
const placements: Placement[] = []
const seen = new Set<TreeItem>()
const used = new Set(names)
const collect = (list: TreeItem[], parent?: TreeItem, parents?: TreeItem[]) => {
for (let index = 0; index < list.length; index++) {
const item = list[index]!
if (!item || typeof item !== 'object')
throw new TypeError('Setting item must be an object')
if (seen.has(item))
throw new Error('Setting items must appear only once in a tree')
seen.add(item)
const previous = bindings.get(item)
if (previous && previous.owner !== ownerState && activeBinding(item))
errorHandle(false, `Setting item [${item.name || ''}] already belongs to another active player`)
if (item.name) {
errorHandle(!used.has(item.name), `The [${item.name}] already exists in [setting]`)
used.add(item.name)
}
if (!bindings.has(item)) {
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(item, key)
if (descriptor ? !descriptor.configurable : !Object.isExtensible(item))
throw new TypeError(`Cannot format setting item property [${key}]`)
}
}
if (!item.name && !canAssignName(item))
throw new TypeError('Cannot assign an automatic setting item name')
placements.push({ item, parent, parents, option: list, name: item.name || '', generated: !item.name })
if (item.selector?.length)
collect(item.selector, item, list)
}
}
collect(option, parent, parents)
let nextId = owner.id
owners.set(owner, ownerState)
ownerState.root = option
for (const placement of placements) {
if (!placement.name) {
do {
placement.name = `setting-${nextId++}`
} while (used.has(placement.name))
used.add(placement.name)
}
}
for (const placement of placements) {
const { item, name, parent, parents, option } = placement
const previous = bindings.get(item)
if (!previous) {
Object.defineProperties(item, {
$parent: { get: () => treeBinding(item).parent },
$parents: { get: () => treeBinding(item).parents },
$option: { get: () => treeBinding(item).option },
$events: { get: () => treeBinding(item).events },
$formatted: { get: () => true },
})
}
bindings.set(item, { parent, parents, option, events: previous?.events || [], owner: ownerState })
if (placement.generated)
item.name = name
}
// Preserve the caller-provided accumulator and monotonic autogenerated IDs.
for (const placement of placements) {
if (!placement.generated)
names.push(placement.name)
}
owner.id = nextId
return option
}
+31
View File
@@ -0,0 +1,31 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingItem, SettingManager } from './types'
import { getScope } from '../lifecycle/instance'
const scopes = new WeakMap<HTMLDivElement, ResourceScope>()
export function ownSettingPanel(setting: SettingManager, option: SettingItem[], panel: HTMLDivElement): ResourceScope {
const scope = getScope(setting.art).child()
scopes.set(panel, scope)
scope.add(() => {
if (setting.cache.get(option) === panel)
setting.cache.delete(option)
panel.remove()
})
return scope
}
export function settingPanelScope(panel: HTMLDivElement): ResourceScope {
const scope = scopes.get(panel)
if (!scope)
throw new Error('Setting panel has not been registered')
return scope
}
export function releaseSettingPanel(panel: HTMLDivElement): void {
const scope = scopes.get(panel)
if (scope)
scope.dispose()
else
panel.remove()
}
@@ -1,11 +1,14 @@
export default function playbackRate(art) {
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function playbackRate(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, PLAYBACK_RATE },
} = art
function getI18n(value) {
function getI18n(value: number) {
return value === 1.0 ? i18n.get('Normal') : value.toFixed(1)
}
@@ -29,12 +32,12 @@ export default function playbackRate(art) {
}
}),
onSelect(item) {
art.playbackRate = item.value
art.playbackRate = item.value as number
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('video:ratechange', () => update())
subscribeSetting(art, item, 'video:ratechange', () => update())
},
}
}
@@ -0,0 +1,20 @@
import type { SettingItem } from './types'
const registrations = new WeakMap<SettingItem, object>()
export function cancelSettingAdd(item: SettingItem): void {
registrations.delete(item)
}
export function beginSettingAdd(item: SettingItem): { current: () => boolean, finish: () => void } {
const token = {}
registrations.set(item, token)
const current = () => registrations.get(item) === token
return {
current,
finish() {
if (current())
registrations.delete(item)
},
}
}
+378
View File
@@ -0,0 +1,378 @@
import type { SettingKind } from './selection'
import type { SettingItem, SettingManager, SettingRange } from './types'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { addClass, append, def, has, inverseClass, setStyle } from '../utils'
import { settingScopeActive } from './activity'
import { captureSettingItem, rememberSettingState } from './checkpoint'
import { ownSettingPanel, releaseSettingPanel, settingPanelScope } from './panels'
import { ownSettingItem, proxySetting, settingItemOwner } from './resources'
import { bindSettingActions } from './selection'
import { captureSettingUpdate } from './update'
const navigations = new WeakMap<SettingManager, number>()
function bindContent(item: SettingItem, key: 'icon' | 'html' | 'tooltip', element: HTMLDivElement): void {
def(item, `$${key}`, { configurable: true, get: () => element })
def(item, key, {
configurable: true,
get: () => element.innerHTML,
set(value: unknown) {
element.innerHTML = ''
append(element, value)
},
})
}
export function createSettingHeader(setting: SettingManager, item: SettingItem): void {
if (!setting.cache.has(item.$option!))
return
const $panel = setting.cache.get(item.$option!)!
const {
icons: { arrowLeft },
constructor: { SETTING_ITEM_HEIGHT },
} = setting.art
const $item = document.createElement('div')
setStyle($item, 'height', `${SETTING_ITEM_HEIGHT}px`)
addClass($item, 'art-setting-item')
addClass($item, 'art-setting-item-back')
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
append($left, $icon)
append($left, item.$parent!.html)
proxySetting(setting.art, item.$parent!, $item, 'click', () => setting.render(item.$parents), settingPanelScope($panel))
append($panel, $item)
}
export function createSettingItem(setting: SettingManager, item: SettingItem, isUpdate = false): void {
const currentUpdate = captureSettingUpdate(item)
if (!setting.cache.has(item.$option!))
return
const $panel = setting.cache.get(item.$option!)!
const oldItem = item.$item
let type: SettingKind = 'selector'
if (has(item, 'switch')) {
type = 'switch'
}
if (has(item, 'range')) {
type = 'range'
}
if (has(item, 'onClick')) {
type = 'button'
}
const { icons, constructor } = setting.art
if (!currentUpdate())
return
const scope = ownSettingItem(setting.art, item, settingPanelScope($panel))
const current = () => settingScopeActive(scope) && !isClosing(setting.art) && currentUpdate()
if (!current())
return
const $item = document.createElement('div')
try {
rememberSettingState(item)
addClass($item, 'art-setting-item')
setStyle($item, 'height', `${constructor.SETTING_ITEM_HEIGHT}px`)
$item.dataset.name = item.name || ''
$item.dataset.value = String(item.value || '')
if (!current())
return
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $right = append($item, '<div class="art-setting-item-right"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
switch (type) {
case 'button':
case 'switch':
case 'range': {
const icon = item.icon || icons.config
if (!current())
return
append($icon, icon)
break
}
case 'selector':
if (item.selector?.length) {
const icon = item.icon || icons.config
if (!current())
return
append($icon, icon)
}
else {
append($icon, icons.check)
}
break
default:
break
}
append($left, $icon)
if (!current())
return
bindContent(item, 'icon', $icon)
const $html = document.createElement('div')
addClass($html, 'art-setting-item-left-text')
const html = item.html || ''
if (!current())
return
append($html, html)
append($left, $html)
bindContent(item, 'html', $html)
const $tooltip = document.createElement('div')
addClass($tooltip, 'art-setting-item-right-tooltip')
const tooltip = item.tooltip || ''
if (!current())
return
append($tooltip, tooltip)
append($right, $tooltip)
bindContent(item, 'tooltip', $tooltip)
switch (type) {
case 'switch': {
const $switch = document.createElement('div')
addClass($switch, 'art-setting-item-right-icon')
const $switchOn = append($switch, icons.switchOn)
const $switchOff = append($switch, icons.switchOff)
const initialSwitch = item.switch
if (!current())
return
setStyle(initialSwitch ? $switchOff : $switchOn, 'display', 'none')
append($right, $switch)
def(item, '$switch', {
configurable: true,
get: () => $switch,
})
let $switchValue = item.switch
if (!current())
return
rememberSettingState(item, () => {
const value = $switchValue
return () => {
$switchValue = value
}
})
def(item, 'switch', {
configurable: true,
get: () => $switchValue,
set(value) {
$switchValue = value
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
}
else {
setStyle($switchOff, 'display', null)
setStyle($switchOn, 'display', 'none')
}
},
})
break
}
case 'range':
{
const $state = document.createElement('div')
addClass($state, 'art-setting-item-right-icon')
const $range = document.createElement('input')
$range.type = 'range'
append($state, $range)
for (const [index, key] of ['value', 'min', 'max', 'step'].entries()) {
const value = item.range![index]
if (!current())
return
Reflect.set($range, key, String(value))
}
addClass($range, 'art-setting-range')
append($right, $state)
def(item, '$range', {
configurable: true,
get: () => $range,
})
let $rangeValue = [...item.range!]
if (!current())
return
rememberSettingState(item, () => {
const value = $rangeValue
const values = [...value]
const input = { min: $range.min, max: $range.max, step: $range.step, value: $range.value }
return () => {
value.splice(0, value.length, ...values)
$rangeValue = value
Object.assign($range, input)
}
})
def(item, 'range', {
configurable: true,
get: () => $rangeValue,
set(value: SettingRange) {
$rangeValue = [...value]
$range.value = String(value[0])
$range.min = String(value[1])
$range.max = String(value[2])
$range.step = String(value[3])
},
})
}
break
case 'selector':
if (item.selector?.length) {
const $state = document.createElement('div')
addClass($state, 'art-setting-item-right-icon')
append($state, icons.arrowRight)
append($right, $state)
}
break
default:
break
}
if (!current())
return
bindSettingActions(setting, item, $item, type)
if (!current())
return
def(item, '$item', {
configurable: true,
get: () => $item,
})
if (isUpdate) {
if (oldItem?.parentNode)
oldItem.replaceWith($item)
else
append($panel, $item)
}
else {
append($panel, $item)
}
if (item.mounted && current()) {
timeout(scope, () => {
if (isClosing(setting.art))
return
try {
const result = item.mounted?.call(setting.art, $item, item)
void Promise.resolve(result).catch(error => console.warn('ArtPlayer setting mounted failed:', error))
}
catch (error) {
console.warn('ArtPlayer setting mounted failed:', error)
}
}, 0)
}
}
catch (error) {
try {
scope.dispose()
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
$item.remove()
throw error
}
}
export function renderSetting(setting: SettingManager, option = setting.option): void {
if (isClosing(setting.art))
return
const cached = setting.cache.get(option)
if (cached && !settingScopeActive(settingPanelScope(cached)))
return
const navigation = (navigations.get(setting) || 0) + 1
navigations.set(setting, navigation)
const currentNavigation = () => navigations.get(setting) === navigation && !isClosing(setting.art)
const previous = setting.active
const previousLayout = ['height', 'width', 'left', 'right'].map(key => [key, setting.$parent.style.getPropertyValue(key), setting.$parent.style.getPropertyPriority(key)] as const)
const restoreNavigation = () => {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
for (const [key, value, priority] of previousLayout)
setting.$parent.style.setProperty(key, value, priority)
}
setting.active = option
if (cached) {
try {
inverseClass(cached, 'art-current')
setting.resize()
}
catch (error) {
if (currentNavigation() && setting.cache.get(option) === cached && !settingPanelScope(cached).closed)
restoreNavigation()
throw error
}
}
else {
const $panel = document.createElement('div')
setting.cache.set(option, $panel)
const scope = ownSettingPanel(setting, option, $panel)
const current = () => !scope.closed && !isClosing(setting.art) && setting.cache.get(option) === $panel
const checkpoints: { item: SettingItem, restore: () => void }[] = []
try {
if (!current())
return
addClass($panel, 'art-setting-panel')
append(setting.$parent, $panel)
inverseClass($panel, 'art-current')
if (option[0]?.$parent)
setting.createHeader(option[0])
for (let index = 0; index < option.length && current(); index++) {
const item = option[index]!
checkpoints.push({ item, restore: captureSettingItem(item) })
setting.createItem(item)
}
if (!current())
return
setting.resize()
}
catch (error) {
const restore = current()
const restoreActive = setting.active === option && currentNavigation()
try {
releaseSettingPanel($panel)
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
if (restore && !isClosing(setting.art)) {
for (const checkpoint of checkpoints.reverse()) {
if (settingItemOwner(checkpoint.item) === scope) {
try {
checkpoint.restore()
}
catch (restoreError) {
console.warn('ArtPlayer setting restore failed:', restoreError)
}
}
}
if (restoreActive)
restoreNavigation()
}
throw error
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingEvents, SettingHost, SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { pauseSettingScope, settingScopeActive } from './activity'
import { hasTreeBinding, treeBinding } from './model'
import { releaseSettingPanel } from './panels'
const scopes = new WeakMap<SettingItem, ResourceScope>()
const owners = new WeakMap<SettingItem, ResourceScope>()
const eventLists = new WeakMap<ResourceScope, (() => void)[]>()
function releaseEvents(art: SettingHost, events: (() => void)[]): void {
const pending = events.splice(0)
const failures: unknown[] = []
for (const cleanup of pending) {
try {
art.events.remove(cleanup)
}
catch (error) {
failures.push(error)
}
}
if (failures.length)
throw new ResourceCleanupError(failures)
}
export function ownSettingItem(art: SettingHost, item: SettingItem, owner = getScope(art)): ResourceScope {
const previous = scopes.get(item)
if (!previous || settingScopeActive(previous) || previous.closed)
releaseSettingItem(art, item)
const scope = owner.child()
scopes.set(item, scope)
owners.set(item, owner)
eventLists.set(scope, treeBinding(item).events)
scope.add(() => {
releaseEvents(art, eventLists.get(scope)!)
})
return scope
}
export function settingItemOwner(item: SettingItem): ResourceScope | undefined {
return owners.get(item)
}
export function settingScope(item: SettingItem): ResourceScope {
const scope = scopes.get(item)
if (!scope)
throw new Error('Setting item has not been rendered')
return scope
}
export function releaseSettingItem(art: SettingHost, item: SettingItem): void {
const scope = scopes.get(item)
if (scope && !scope.closed)
scope.dispose()
else if (hasTreeBinding(item))
releaseEvents(art, treeBinding(item).events)
}
export interface SettingSuspension {
resume: () => void
dispose: () => void
}
export function suspendSettingItem(art: SettingHost, item: SettingItem): SettingSuspension | undefined {
const previous = scopes.get(item)
if (!previous || previous.closed)
return
if (!settingScopeActive(previous))
throw new Error('Setting item update is already in progress')
const owner = owners.get(item)!
const events = treeBinding(item).events
const saved = events.splice(0)
eventLists.set(previous, saved)
const unpause = pauseSettingScope(previous)
let pending = true
return {
resume() {
if (!pending)
return
pending = false
const failures: unknown[] = []
const current = scopes.get(item)
for (const cleanup of [() => {
if (current && current !== previous)
current.dispose()
}, () => releaseEvents(art, events)]) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
if (!previous.closed && !isClosing(art)) {
events.push(...saved.splice(0))
eventLists.set(previous, events)
scopes.set(item, previous)
owners.set(item, owner)
}
unpause()
if (failures.length)
throw new ResourceCleanupError(failures)
},
dispose() {
if (!pending)
return
pending = false
try {
previous.dispose()
}
finally {
unpause()
}
},
}
}
export function proxySetting(art: SettingHost, item: SettingItem, target: EventTarget, name: string, callback: (event: Event) => unknown, owner?: ResourceScope): void {
const scope = owner || settingScope(item)
if (!settingScopeActive(scope) || isClosing(art))
return
const event = art.proxy(target, name, (event) => {
if (settingScopeActive(scope) && !isClosing(art)) {
try {
return callback(event)
}
catch (error) {
console.warn('ArtPlayer setting callback failed:', error)
}
}
})
if (!settingScopeActive(scope) || isClosing(art)) {
art.events.remove(event)
}
else {
treeBinding(item).events.push(event)
if (owner && owner !== scopes.get(item)) {
owner.add(() => {
const events = treeBinding(item).events
const index = events.indexOf(event)
if (index >= 0) {
events.splice(index, 1)
art.events.remove(event)
}
})
}
}
}
export function subscribeSetting<Name extends keyof SettingEvents>(art: SettingHost, item: SettingItem, name: Name, callback: (...args: SettingEvents[Name]) => unknown): void {
const scope = settingScope(item)
if (!settingScopeActive(scope) || isClosing(art))
return
const guarded = (...args: SettingEvents[Name]) => {
if (settingScopeActive(scope) && !isClosing(art))
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
export function releaseSettingTree(setting: SettingManager, item: SettingItem): void {
const failures: unknown[] = []
const visited = new Set<SettingItem>()
const visit = (item: SettingItem) => {
if (visited.has(item))
return
visited.add(item)
for (const child of item.selector || [])
visit(child)
if (item.selector) {
const panel = setting.cache.get(item.selector)
setting.cache.delete(item.selector)
try {
if (panel)
releaseSettingPanel(panel)
}
catch (error) {
failures.push(error)
}
}
try {
releaseSettingItem(setting.art, item)
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
visit(item)
if (failures.length)
throw new ResourceCleanupError(failures)
}
@@ -0,0 +1,83 @@
import type { SettingCallback, SettingItem, SettingManager } from './types'
import { isClosing } from '../lifecycle/instance'
import { addClass, inverseClass } from '../utils/dom'
import { settingScopeActive } from './activity'
import { proxySetting, settingScope } from './resources'
export type SettingKind = 'button' | 'selector' | 'switch' | 'range'
const generations = new WeakMap<SettingItem, number>()
export function checkSetting(setting: SettingManager, target?: SettingItem | null): void {
if (!target?.$parent)
return
target.$parent.tooltip = target.html
setting.traverse((item) => {
item.default = item === target
if (item.default && item.$item)
inverseClass(item.$item, 'art-current')
}, target.$option)
setting.render(target.$parents)
}
async function act(setting: SettingManager, item: SettingItem, element: HTMLDivElement, event: Event, callback: SettingCallback, target: SettingItem, property: 'switch' | 'tooltip', before?: () => void): Promise<void> {
try {
const scope = settingScope(item)
const targetScope = settingScope(target)
const generation = (generations.get(target) || 0) + 1
generations.set(target, generation)
const active = () => settingScopeActive(scope) && settingScopeActive(targetScope) && !isClosing(setting.art) && generations.get(target) === generation
before?.()
if (!active())
return
const value = await callback.call(setting.art, item, element, event)
if (active())
Reflect.set(target, property, value)
}
catch (error) {
console.warn('ArtPlayer setting callback failed:', error)
}
}
export function bindSettingActions(setting: SettingManager, item: SettingItem, element: HTMLDivElement, kind: SettingKind): void {
const { art } = setting
const scope = settingScope(item)
const proxy = (target: EventTarget, name: string, callback: (event: Event) => unknown) => proxySetting(art, item, target, name, callback, scope)
switch (kind) {
case 'switch':
if (item.onSwitch)
proxy(element, 'click', event => act(setting, item, element, event, item.onSwitch!, item, 'switch'))
break
case 'range': {
const range = item.$range
if (range) {
for (const [name, callback] of [['change', 'onRange'], ['input', 'onChange']] as const) {
if (item[callback]) {
proxy(range, name, event => act(setting, item, element, event, item[callback]!, item, 'tooltip', () => {
item.range![0] = range.valueAsNumber
}))
}
}
}
break
}
case 'selector':
proxy(element, 'click', (event) => {
if (item.selector?.length) {
setting.render(item.selector)
}
else if (item.$parent?.onSelect) {
return act(setting, item, element, event, item.$parent.onSelect, item.$parent, 'tooltip', () => setting.check(item))
}
else {
setting.check(item)
}
})
if (item.default)
addClass(element, 'art-current')
break
case 'button':
if (item.onClick)
proxy(element, 'click', event => act(setting, item, element, event, item.onClick!, item, 'tooltip'))
break
}
}
@@ -1,22 +0,0 @@
export default function subtitleOffset(art) {
const { i18n, icons, constructor } = art
return {
width: constructor.SETTING_ITEM_WIDTH,
name: 'subtitle-offset',
html: i18n.get('Subtitle Offset'),
icon: icons.subtitle,
tooltip: '0s',
range: [0, -10, 10, 0.1],
onChange(item) {
art.subtitleOffset = item.range[0]
return `${item.range[0]}s`
},
mounted: (_, item) => {
art.on('subtitleOffset', (value) => {
item.$range.value = value
item.tooltip = `${value}s`
})
},
}
}
@@ -0,0 +1,25 @@
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function subtitleOffset(art: SettingHost): SettingItem {
const { i18n, icons, constructor } = art
return {
width: constructor.SETTING_ITEM_WIDTH,
name: 'subtitle-offset',
html: i18n.get('Subtitle Offset'),
icon: icons.subtitle,
tooltip: '0s',
range: [0, -10, 10, 0.1],
onChange(item) {
art.subtitleOffset = item.range![0]!
return `${item.range![0]}s`
},
mounted: (_, item) => {
subscribeSetting(art, item, 'subtitleOffset', (value) => {
item.$range!.value = String(value)
item.tooltip = `${value}s`
})
},
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type { TreeItem } from './model'
export type SettingContent = string | HTMLElement | number
export type SettingRange = [value?: number, min?: number, max?: number, step?: number]
export type SettingCallback = (this: SettingHost, item: SettingItem, element: HTMLDivElement, event: Event) => unknown
export interface SettingItem extends TreeItem {
html?: SettingContent
icon?: SettingContent
tooltip?: SettingContent
width?: number
value?: string | number
default?: boolean
switch?: boolean
range?: SettingRange
selector?: SettingItem[]
mounted?: (this: SettingHost, element: HTMLDivElement, item: SettingItem) => unknown
onClick?: SettingCallback
onSwitch?: SettingCallback
onRange?: SettingCallback
onChange?: SettingCallback
onSelect?: SettingCallback
readonly $parent?: SettingItem
readonly $parents?: SettingItem[]
readonly $option?: SettingItem[]
readonly $item?: HTMLDivElement
readonly $icon?: HTMLDivElement
readonly $html?: HTMLDivElement
readonly $tooltip?: HTMLDivElement
readonly $switch?: HTMLDivElement
readonly $range?: HTMLInputElement
}
export interface SettingEvents {
'blur': []
'focus': [event: Event]
'resize': []
'video:ratechange': []
'aspectRatio': [value: string]
'flip': [value: string]
'subtitleOffset': [value: number]
}
export interface SettingHost extends ComponentHost, SubscriptionHost<SettingEvents> {
template: { $player: HTMLElement, $setting: HTMLElement, $bottom: HTMLElement, $controls: HTMLElement }
controls: { setting?: HTMLElement }
option: { setting: boolean, settings: SettingItem[], playbackRate: boolean, aspectRatio: boolean, flip: boolean, subtitleOffset: boolean }
constructor: { SETTING_WIDTH: number, SETTING_ITEM_HEIGHT: number, SETTING_ITEM_WIDTH: number, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
icons: Record<'arrowLeft' | 'arrowRight' | 'config' | 'check' | 'switchOn' | 'switchOff' | 'playbackRate' | 'aspectRatio' | 'flip' | 'subtitle', HTMLElement>
proxy: ComponentHost['events']['proxy']
i18n: { get: (key: string) => string }
setting: SettingManager
isRotate: boolean
playbackRate: number
aspectRatio: string
flip: string
subtitleOffset: number
}
export interface SettingManager {
id: number
art: SettingHost
name: string
$parent: HTMLElement
cache: Map<SettingItem[], HTMLDivElement>
active: SettingItem[] | null
option: SettingItem[]
show: boolean
traverse: (callback: (item: SettingItem) => void, option?: SettingItem[]) => void
check: (target?: SettingItem | null) => void
render: (option?: SettingItem[]) => void
resize: () => void
createItem: (item: SettingItem, isUpdate?: boolean) => void
createHeader: (item: SettingItem) => void
find: (name?: string) => SettingItem | null
format: () => void
add: <Item extends SettingItem>(item: Item, option?: SettingItem[]) => Item
}
+210
View File
@@ -0,0 +1,210 @@
import type { SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import captureTemplate from '../lifecycle/template-rollback'
import { inverseClass } from '../utils/dom'
import { pauseSettingScope } from './activity'
import { captureSettingItem } from './checkpoint'
import { releaseSettingPanel, settingPanelScope } from './panels'
import { cancelSettingAdd } from './registration'
import { suspendSettingItem } from './resources'
const pending = new WeakMap<SettingItem, () => void>()
function finish(cleanups: (() => void)[]): void {
const errors: unknown[] = []
for (const cleanup of cleanups) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
errors.push(...error.errors)
else
errors.push(error)
}
}
if (errors.length)
throw new ResourceCleanupError(errors)
}
function suspendTree(setting: SettingManager, root: SettingItem): { items: Set<SettingItem>, resume: () => void, dispose: () => void } {
const items = new Set<SettingItem>()
const panels = new Set<HTMLDivElement>()
const collect = (item: SettingItem) => {
if (items.has(item))
return
items.add(item)
if (item.selector) {
const panel = setting.cache.get(item.selector)
if (panel)
panels.add(panel)
item.selector.forEach(collect)
}
}
collect(root)
const suspended: NonNullable<ReturnType<typeof suspendSettingItem>>[] = []
const unpause: (() => void)[] = []
try {
for (const item of items) {
const suspension = suspendSettingItem(setting.art, item)
if (suspension)
suspended.push(suspension)
}
for (const panel of panels)
unpause.push(pauseSettingScope(settingPanelScope(panel)))
}
catch (error) {
try {
finish([...suspended.reverse().map(item => item.resume), ...unpause])
}
catch (cleanupError) {
console.warn('ArtPlayer setting restore failed:', cleanupError)
}
throw error
}
let active = true
return {
items,
resume() {
if (!active)
return
active = false
finish([...suspended.reverse().map(item => item.resume), ...unpause])
},
dispose() {
if (!active)
return
active = false
finish([...suspended.reverse().map(item => item.dispose), ...Array.from(panels).reverse().map(panel => () => releaseSettingPanel(panel)), ...unpause])
},
}
}
// A newer operation restores the pending one before applying its own changes.
export function cancelSettingUpdate(item: SettingItem): void {
pending.get(item)?.()
}
export function captureSettingUpdate(item: SettingItem): () => boolean {
const operation = pending.get(item)
return () => !operation || pending.get(item) === operation
}
function assignSetting(item: SettingItem, target: SettingItem, current: () => boolean): void {
for (const key of Reflect.ownKeys(target)) {
if (!current())
return
const descriptor = Object.getOwnPropertyDescriptor(target, key)
if (!descriptor?.enumerable || !current())
continue
const value: unknown = Reflect.get(target, key)
if (!current())
return
if (!Reflect.set(item, key, value))
throw new TypeError(`Cannot assign setting item property [${String(key)}]`)
}
}
export function updateSetting(setting: SettingManager, target: SettingItem): SettingItem {
const item = setting.find(target.name)
if (isClosing(setting.art))
return item || target
if (!item)
return setting.add(target)
cancelSettingAdd(item)
cancelSettingUpdate(item)
const restoreItem = captureSettingItem(item)
const element = item.$item
const parent = element?.parentNode
const next = element?.nextSibling
const restoreTemplate = element ? captureTemplate(element) : undefined
const previous = setting.active
const layout = ['height', 'width', 'left', 'right'].map(key => [key, setting.$parent.style.getPropertyValue(key), setting.$parent.style.getPropertyPriority(key)] as const)
const tree = suspendTree(setting, item)
let active = true
let rendered = false
const cancel = () => {
if (!active)
return
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
if (isClosing(setting.art)) {
tree.dispose()
return
}
const replacement = item.$item
try {
finish([
tree.resume,
() => {
if (replacement && replacement !== element)
replacement.remove()
},
restoreItem,
() => restoreTemplate?.(),
() => {
if (parent && element)
parent.insertBefore(element, next?.parentNode === parent ? next : null)
},
() => setting.format(),
() => {
if (rendered) {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
for (const [key, value, priority] of layout)
setting.$parent.style.setProperty(key, value, priority)
}
},
])
}
catch (error) {
console.warn('ArtPlayer setting restore failed:', error)
}
}
const current = () => active && !isClosing(setting.art) && pending.get(item) === cancel
for (const affected of tree.items)
pending.set(affected, cancel)
const release = getScope(setting.art).add(() => {
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
tree.dispose()
})
try {
assignSetting(item, target, current)
if (!current())
return item
setting.format()
if (!current())
return item
setting.createItem(item, true)
if (!current())
return item
rendered = true
setting.render()
if (!current())
return item
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
tree.dispose()
return item
}
catch (error) {
if (current())
cancel()
throw error
}
finally {
release()
}
}