refactor(core): [CORE-20] type entry and converge initialization

This commit is contained in:
Harvey Zhao committed 2026-09-11 16:07:06 +08:00
1 parent ccf77c4ed2
commit ee895a558f
100 files changed
+33854 -839

No files matched your search

@@ -0,0 +1,21 @@
import { setStyleText } from '../style/inject'
import { isBrowser } from '../utils/compatibility'
export default function publishBrowserEntry<Constructor extends { version: string, LOG_VERSION: boolean }>(Artplayer: Constructor, style: string): void {
if (!isBrowser)
return
(window as Window & { Artplayer?: Constructor }).Artplayer = Artplayer
setStyleText('artplayer-style', style)
setTimeout(() => {
if (Artplayer.LOG_VERSION) {
// eslint-disable-next-line no-console
console.log(
`%c ArtPlayer %c ${Artplayer.version} %c https://artplayer.org`,
'color: #fff; background: #5f5f5f',
'color: #fff; background: #4bc729',
'',
)
}
}, 100)
}
@@ -1,4 +1,12 @@
export default {
export interface MediaConfig {
properties: string[]
methods: string[]
events: string[]
prototypes: string[]
}
// Public arrays intentionally remain mutable and retain their historical order.
const config: MediaConfig = {
properties: [
'audioTracks',
'autoplay',
@@ -79,3 +87,5 @@ export default {
'webkitExitFullscreen',
],
}
export default config
+2
View File
@@ -18,6 +18,8 @@ import { checkSelector, renderSelector } from './selector'
export default class Control extends Component<ControlHost> {
declare isHover: boolean
declare timer: number
declare setting?: HTMLDivElement
declare thumbnails?: HTMLDivElement
constructor(art: ControlHost) {
super(art)
@@ -7,7 +7,8 @@ export interface ProgressPositionHost {
isRotate: boolean
top: number
height: number
seek: number | string
get seek(): undefined
set seek(value: number | string)
emit: (name: 'setBar', kind: 'played', percentage: number, event: ProgressEvent) => unknown
}
+2 -2
View File
@@ -37,7 +37,7 @@ export function checkSelector(target?: SelectorItem): void {
const item = target.$control_option![index]!
item.default = item === target
if (item.default)
inverseClass(item.$control_item, 'art-current')
inverseClass(item.$control_item!, 'art-current')
}
}
@@ -69,7 +69,7 @@ export function renderSelector(art: ControlHost, check: (target?: SelectorItem)
if (scope.closed)
return
const path = getComposedPath(event)
const item = option.selector!.find(item => path.includes(item.$control_item))
const item = option.selector!.find(item => path.includes(item.$control_item!))
if (!item)
return
const current = ++generation
+6 -4
View File
@@ -1,5 +1,6 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
import type { PipProperty } from '../display/types'
export interface UIEvents {
'mousemove': [MouseEvent]
@@ -27,11 +28,11 @@ export interface UIEvents {
'resize': []
}
export interface QualityItem { html: string, url: string, default?: boolean }
export interface QualityItem { html: string | HTMLElement, url: string, default?: boolean }
type IconName = 'play' | 'pause' | 'volume' | 'volumeClose' | 'fullscreenOn' | 'fullscreenOff' | 'fullscreenWebOn' | 'fullscreenWebOff' | 'pip' | 'setting' | 'screenshot' | 'airplay'
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents> {
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents>, PipProperty {
template: {
$player: HTMLDivElement
$bottom: HTMLDivElement
@@ -67,10 +68,11 @@ export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents> {
loaded: number
top: number
height: number
fullscreen: boolean
fullscreen?: boolean
fullscreenWeb: boolean
pip: boolean
get seek(): undefined
set seek(second: number)
get quality(): undefined
set quality(value: QualityItem[])
play: () => unknown
pause: () => unknown
@@ -1,11 +1,12 @@
import type { SubscriptionHost } from '../component/resources'
import type { NoticeSink } from '../notice'
export interface OrientationHost extends SubscriptionHost<{ fullscreenWeb: [boolean], fullscreen: [boolean] }> {
constructor: { AUTO_ORIENTATION_TIME?: number }
template: { $player: HTMLElement, $video: { readonly videoWidth: number, readonly videoHeight: number } }
fullscreenWeb: boolean
isRotate: boolean
notice: { set show(value: unknown) }
notice: NoticeSink
emit: (name: 'resize') => unknown
}
+9 -9
View File
@@ -1,23 +1,23 @@
import type { SubscriptionHost } from '../component/resources'
import type { CoreEmission } from '../events/core-types'
import type { OptionalMediaCapabilities } from '../media/types'
import type { NoticeSink } from '../notice'
export interface WebFullscreenHost {
export interface WebFullscreenHost extends CoreEmission<'fullscreenWeb' | 'resize'> {
constructor: { FULLSCREEN_WEB_IN_BODY: boolean }
template: { $container: HTMLElement, $player: HTMLElement }
state: string
emit: (name: 'fullscreenWeb' | 'resize', ...args: unknown[]) => unknown
}
export interface WebFullscreenProperty {
fullscreenWeb: boolean
}
export interface FullscreenHost {
export interface FullscreenHost extends CoreEmission<'fullscreen' | 'fullscreenError' | 'resize'> {
template: { $player: HTMLElement, $video: HTMLElement & OptionalMediaCapabilities }
state: string
notice: { set show(value: unknown) }
notice: NoticeSink
i18n: { get: (key: string) => string }
emit: (name: 'fullscreen' | 'fullscreenError' | 'resize', ...args: unknown[]) => unknown
once: (name: 'video:loadedmetadata', listener: () => void) => unknown
on: (name: 'document:webkitfullscreenchange', listener: () => void) => unknown
off: (name: 'video:loadedmetadata' | 'document:webkitfullscreenchange', listener: () => void) => unknown
@@ -37,7 +37,7 @@ export interface NativeFullscreenAdapter {
export interface PipHost {
template: { $video: HTMLElement & OptionalMediaCapabilities & { disablePictureInPicture?: boolean } }
state: string
notice: { set show(value: unknown) }
notice: NoticeSink
i18n: { get: (key: string) => string }
emit: (name: 'pip', value: boolean) => unknown
}
@@ -48,9 +48,9 @@ export interface PipProperty {
}
export interface MiniEvents {
'video:playing': []
'video:pause': []
'video:timeupdate': []
'video:playing': [Event]
'video:pause': [Event]
'video:timeupdate': [Event]
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
}
+22
View File
@@ -0,0 +1,22 @@
interface EventPathSource {
target: EventTarget | null
composedPath?: () => EventTarget[]
}
export function getComposedPath(event: EventPathSource): EventTarget[] {
if (event.composedPath)
return event.composedPath()
const path: EventTarget[] = []
let node = event.target
while (node) {
path.push(node)
node = (node as EventTarget & { parentNode?: EventTarget | null }).parentNode ?? null
}
if (typeof window !== 'undefined' && !path.includes(window))
path.push(window)
return path
}
export function includeFromEvent(event: EventPathSource, target: EventTarget | null | undefined): boolean {
return getComposedPath(event).includes(target as EventTarget)
}
+37
View File
@@ -0,0 +1,37 @@
export function supportsFlex(): boolean {
const div = document.createElement('div')
div.style.display = 'flex'
return div.style.display === 'flex'
}
export function getRect(el: Pick<Element, 'getBoundingClientRect'>): DOMRect {
return el.getBoundingClientRect()
}
export function isInViewport(el: Pick<Element, 'getBoundingClientRect'>, offset = 0): boolean {
const rect = el.getBoundingClientRect()
const windowHeight = window.innerHeight || document.documentElement.clientHeight
const windowWidth = window.innerWidth || document.documentElement.clientWidth
const vertInView = rect.top - offset <= windowHeight && rect.top + rect.height + offset >= 0
const horInView = rect.left - offset <= windowWidth + offset && rect.left + rect.width + offset >= 0
return vertInView && horInView
}
export function getSafeAreaInsets(): { top: number, right: number, bottom: number, left: number } {
const div = document.createElement('div')
div.style.cssText
= 'position:fixed;top:env(safe-area-inset-top,0px);right:env(safe-area-inset-right,0px);bottom:env(safe-area-inset-bottom,0px);left:env(safe-area-inset-left,0px);pointer-events:none;visibility:hidden;'
try {
document.body.appendChild(div)
const style = getComputedStyle(div)
return {
top: Number.parseFloat(style.top) || 0,
right: Number.parseFloat(style.right) || 0,
bottom: Number.parseFloat(style.bottom) || 0,
left: Number.parseFloat(style.left) || 0,
}
}
finally {
div.remove()
}
}
@@ -0,0 +1,18 @@
import { isMobile } from '../utils/compatibility'
import { addClass, append, createElement } from './tree'
export function tooltip(target: Element, msg: string | number, pos = 'top'): void {
if (isMobile)
return
target.setAttribute('aria-label', msg as string)
addClass(target, 'hint--rounded')
addClass(target, `hint--${pos}`)
}
export function getIcon(key = '', html: string | HTMLElement = ''): HTMLElement {
const icon = createElement('i')
addClass(icon, 'art-icon')
addClass(icon, `art-icon-${key}`)
append(icon, html)
return icon
}
+22
View File
@@ -0,0 +1,22 @@
interface StyledElement { style: CSSStyleDeclaration }
export function setStyle<T extends StyledElement>(element: T, key: PropertyKey, value: unknown): T {
// Preserve bracket assignment, including native conversion and thrown setters.
const style = element.style as unknown as Record<PropertyKey, unknown>
style[key] = value
return element
}
export function setStyles<T extends StyledElement>(element: T, styles: object): T {
for (const key in styles)
setStyle(element, key, (styles as Record<string, unknown>)[key])
return element
}
export function getStyle(element: Element, key: string, numberType?: true): number
export function getStyle(element: Element, key: string, numberType: false): string
export function getStyle(element: Element, key: string, numberType: boolean): number | string
export function getStyle(element: Element, key: string, numberType = true): number | string {
const value = window.getComputedStyle(element, null).getPropertyValue(key)
return numberType ? Number.parseFloat(value) : value
}
+54
View File
@@ -0,0 +1,54 @@
export function query<T extends Element = Element>(selector: string, parent: ParentNode = document): T | null {
return parent.querySelector<T>(selector)
}
export function queryAll<T extends Element = Element>(selector: string, parent: ParentNode = document): T[] {
return Array.from(parent.querySelectorAll<T>(selector))
}
export function addClass(target: Pick<Element, 'classList'>, className: string): void {
return target.classList.add(className)
}
export function removeClass(target: Pick<Element, 'classList'>, className: string): void {
return target.classList.remove(className)
}
export function hasClass(target: Pick<Element, 'classList'>, className: string): boolean {
return target.classList.contains(className)
}
export function append(parent: Element, child: unknown): Element | ChildNode | null {
if (child instanceof Element) {
parent.appendChild(child)
}
else {
parent.insertAdjacentHTML('beforeend', String(child))
}
return parent.lastElementChild || parent.lastChild
}
export function remove<T extends Node>(child: T): T {
// Detached nodes retain their historical failure rather than becoming no-ops.
return child.parentNode!.removeChild(child)
}
export function siblings(target: Element): Element[] {
return Array.from(target.parentElement!.children).filter(item => item !== target)
}
export function inverseClass(target: Element, className: string): void {
siblings(target).forEach(item => removeClass(item, className))
addClass(target, className)
}
export function replaceElement<T extends Node>(newChild: T, oldChild: Node): T {
oldChild.parentNode!.replaceChild(newChild, oldChild)
return newChild
}
export function createElement<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K]
export function createElement(tag: string): HTMLElement
export function createElement(tag: string): HTMLElement {
return document.createElement(tag)
}
@@ -0,0 +1,30 @@
import type { UIEvents } from '../control/types'
// The emitter stays open to extension names. Known native events retain their
// payloads so subsystem subscriptions are checked against the assembled player.
export interface CoreEvents extends UIEvents, Record<PropertyKey, unknown[]> {
[name: `video:${string}`]: [event: Event]
'ready': []
'error': [error: unknown, attempt: number]
'fullscreenError': [error: unknown]
'destroy': []
'restart': [string]
'focus': [Event]
'lock': [boolean]
'view': [boolean]
'subtitleOffset': [number]
'seek': [time: number, requestedTime: number | string]
'document:click': [Event]
'document:contextmenu': [Event]
'document:keydown': [KeyboardEvent]
'document:touchmove': [Event]
'document:touchend': [Event]
'document:touchcancel': [Event]
'window:resize': [Event]
'window:orientationchange': [Event]
'window:scroll': [Event]
}
export interface CoreEmission<Names extends keyof CoreEvents = keyof CoreEvents> {
emit: <Name extends Names>(name: Name, ...args: [...CoreEvents[Name]]) => unknown
}
+1 -1
View File
@@ -5,6 +5,6 @@ export default function moveInit(art: MoveHost, events: Pick<PointerRegistry, 'p
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
if (!isClosing(art))
art.emit('mousemove', event)
art.emit('mousemove', event as MouseEvent)
})
}
@@ -1,4 +1,5 @@
import type { SubscriptionHost } from '../component/resources'
import type { NoticeSink } from '../notice'
export interface ResizeEvents {
'resize': []
@@ -13,7 +14,7 @@ export interface ResizeHost extends SubscriptionHost<ResizeEvents> {
aspectRatio: string
state: string
autoSize: () => unknown
notice: { show: string }
notice: NoticeSink
emit: (name: 'resize') => unknown
}
+8 -2
View File
@@ -1,10 +1,14 @@
import { def, getIcon } from '../utils'
import defaults from './defaults'
export default class Icons {
// eslint-disable-next-line ts/no-unsafe-declaration-merging -- The constructor defines a getter for every default key without class fields.
interface Icons extends Record<keyof typeof defaults, HTMLElement> {}
// eslint-disable-next-line ts/no-unsafe-declaration-merging -- The merged mapped interface describes the getters installed below.
class Icons {
readonly [key: string]: HTMLElement
constructor(art: { option: { icons: Record<string, string | HTMLElement> } }) {
constructor(art: { option: { icons: Readonly<Record<string, string | HTMLElement | undefined>> } }) {
const icons: Record<string, string | HTMLElement | undefined> = { ...defaults, ...art.option.icons }
for (const key in icons) {
def(this, key, {
@@ -13,3 +17,5 @@ export default class Icons {
}
}
}
export default Icons
@@ -1,5 +1,11 @@
import type { CoreEvents } from './events/core-types'
import type { RuntimeOption } from './option/runtime'
import type { OptionInput } from './option/types'
import type { PlayerProperties } from './player/properties'
import type { PlayerTemplate } from './template/types'
import validator from 'option-validator'
import { version } from '../package.json'
import publishBrowserEntry from './bootstrap/browser'
import config from './config'
import Contextmenu from './contextmenu'
import Control from './control'
@@ -14,7 +20,7 @@ import Loading from './loading'
import Mask from './mask'
import Notice from './notice'
import createDefaults from './option/defaults'
import resolveOption from './option/resolve'
import resolveRuntimeOption from './option/runtime'
import Player from './player'
import Plugins from './plugins'
import scheme from './scheme'
@@ -27,9 +33,76 @@ import * as utils from './utils'
import Emitter from './utils/emitter'
let id = 0
const instances = []
export default class Artplayer extends Emitter {
constructor(option, readyCallback) {
const instances: Artplayer[] = []
// eslint-disable-next-line ts/no-unsafe-declaration-merging -- Player installs these descriptors; emitting fields would shadow them.
interface Artplayer extends PlayerProperties {
constructor: typeof Artplayer
}
// eslint-disable-next-line ts/no-unsafe-declaration-merging -- The merged interface describes installed descriptors and the inherited constructor.
class Artplayer extends Emitter<CoreEvents> {
declare static STYLE: string
declare static DEBUG: boolean
declare static CONTEXTMENU: boolean
declare static NOTICE_TIME: number
declare static SETTING_WIDTH: number
declare static SETTING_ITEM_WIDTH: number
declare static SETTING_ITEM_HEIGHT: number
declare static RESIZE_TIME: number
declare static SCROLL_TIME: number
declare static SCROLL_GAP: number
declare static AUTO_PLAYBACK_MAX: number
declare static AUTO_PLAYBACK_MIN: number
declare static AUTO_PLAYBACK_TIMEOUT: number
declare static RECONNECT_TIME_MAX: number
declare static RECONNECT_SLEEP_TIME: number
declare static CONTROL_HIDE_TIME: number
declare static DBCLICK_TIME: number
declare static DBCLICK_FULLSCREEN: boolean
declare static MOBILE_DBCLICK_PLAY: boolean
declare static MOBILE_CLICK_PLAY: boolean
declare static AUTO_ORIENTATION_TIME: number
declare static INFO_LOOP_TIME: number
declare static FAST_FORWARD_VALUE: number
declare static FAST_FORWARD_TIME: number
declare static TOUCH_MOVE_RATIO: number
declare static VOLUME_STEP: number
declare static SEEK_STEP: number
declare static PLAYBACK_RATE: number[]
declare static ASPECT_RATIO: string[]
declare static FLIP: string[]
declare static FULLSCREEN_WEB_IN_BODY: boolean
declare static LOG_VERSION: boolean
declare static USE_RAF: boolean
declare static REMOVE_SRC_WHEN_DESTROY: boolean
declare id: number
declare option: RuntimeOption<Artplayer>
declare isLock: boolean
declare isReady: boolean
declare isFocus: boolean
declare isInput: boolean
declare isRotate: boolean
declare isDestroy: boolean
declare template: PlayerTemplate<Artplayer>
declare events: Events
declare storage: Storage
declare icons: Icons
declare i18n: I18n
declare notice: Notice
declare player: Player<Artplayer>
declare layers: Layer
declare controls: Control
declare contextmenu: Contextmenu
declare subtitle: Subtitle
declare info: Info
declare loading: Loading
declare hotkey: Hotkey<Artplayer>
declare mask: Mask
declare setting: Setting
declare plugins: Plugins<Artplayer>
constructor(option: OptionInput, readyCallback?: (this: Artplayer, art: Artplayer) => unknown) {
super()
if (!utils.isBrowser) {
@@ -38,7 +111,7 @@ export default class Artplayer extends Emitter {
this.id = ++id
this.option = resolveOption(option, Artplayer.option)
this.option = resolveRuntimeOption<Artplayer>(option, Artplayer.option)
this.isLock = false
this.isReady = false
@@ -49,22 +122,54 @@ export default class Artplayer extends Emitter {
beginLifecycle(this)
try {
this.template = new Template(this)
this.template = new Template<Artplayer>(this) as PlayerTemplate<Artplayer>
if (getScope(this).closed)
return
this.events = new Events(this)
this.storage = new Storage(this)
if (getScope(this).closed)
return
this.storage = new Storage()
if (getScope(this).closed)
return
this.icons = new Icons(this)
if (getScope(this).closed)
return
this.i18n = new I18n(this)
if (getScope(this).closed)
return
this.notice = new Notice(this)
this.player = new Player(this)
if (getScope(this).closed)
return
this.player = new Player<Artplayer>(this)
if (getScope(this).closed)
return
this.layers = new Layer(this)
if (getScope(this).closed)
return
this.controls = new Control(this)
if (getScope(this).closed)
return
this.contextmenu = new Contextmenu(this)
if (getScope(this).closed)
return
this.subtitle = new Subtitle(this)
if (getScope(this).closed)
return
this.info = new Info(this)
if (getScope(this).closed)
return
this.loading = new Loading(this)
this.hotkey = new Hotkey(this)
if (getScope(this).closed)
return
this.hotkey = new Hotkey<Artplayer>(this)
if (getScope(this).closed)
return
this.mask = new Mask(this)
if (getScope(this).closed)
return
this.setting = new Setting(this)
if (getScope(this).closed)
return
this.plugins = new Plugins(this)
if (getScope(this).closed)
@@ -76,7 +181,7 @@ export default class Artplayer extends Emitter {
if (Artplayer.DEBUG) {
// eslint-disable-next-line no-console
const log = msg => console.log(`[ART.${this.id}] -> ${msg}`)
const log = (msg: string) => 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}`))
@@ -194,19 +299,6 @@ Artplayer.LOG_VERSION = true
Artplayer.USE_RAF = false
Artplayer.REMOVE_SRC_WHEN_DESTROY = true
if (utils.isBrowser) {
window.Artplayer = Artplayer
utils.setStyleText('artplayer-style', style)
publishBrowserEntry(Artplayer, style)
setTimeout(() => {
if (Artplayer.LOG_VERSION) {
// eslint-disable-next-line no-console
console.log(
`%c ArtPlayer %c ${Artplayer.version} %c https://artplayer.org`,
'color: #fff; background: #5f5f5f',
'color: #fff; background: #4bc729',
'',
)
}
}, 100)
}
export default Artplayer
@@ -1,5 +1,6 @@
import type { SubscriptionHost } from '../component/resources'
import type { ProgressPositionHost } from '../control/progress/position'
import type { NoticeSink } from '../notice'
export interface GestureHost extends ProgressPositionHost, SubscriptionHost<{
'document:touchend': [Event]
@@ -12,7 +13,7 @@ export interface GestureHost extends ProgressPositionHost, SubscriptionHost<{
isLock: boolean
width: number
currentTime: number
notice: { show: string }
notice: NoticeSink
}
export interface Drag {
+4 -2
View File
@@ -6,8 +6,10 @@ export interface HotkeyHost extends SubscriptionHost<{ 'document:keydown': [Keyb
template: { $player: HTMLElement }
isFocus: boolean
fullscreenWeb: boolean
backward: number
forward: number
get backward(): undefined
set backward(value: number)
get forward(): undefined
set forward(value: number)
volume: number
toggle: () => unknown
emit: (name: 'hotkey' | 'keydown', event: KeyboardEvent) => unknown
@@ -23,7 +23,7 @@ export interface ClickHost extends FocusHost {
template: { $player: HTMLElement, $video: EventTarget }
isLock: boolean
fullscreen: boolean
fullscreen?: boolean
toggle: () => unknown
emit: (name: 'focus' | 'blur' | 'click' | 'dblclick', event: Event) => unknown
}
@@ -35,5 +35,5 @@ export interface HoverHost {
export interface MoveHost {
template: { $player: HTMLElement }
emit: (name: 'mousemove', event: Event) => unknown
emit: (name: 'mousemove', event: MouseEvent) => unknown
}
+22 -2
View File
@@ -1,10 +1,15 @@
import ResourceScope, { ResourceCleanupError } from './scope'
interface LifecycleTemplate {
$video?: unknown
destroy: (removeHtml: boolean) => void
}
interface LifecycleOwner {
isDestroy: boolean
reset: () => void
emit: (name: 'destroy') => unknown
template?: { $video?: unknown, destroy: (removeHtml: boolean) => void }
template?: LifecycleTemplate
}
interface State {
@@ -13,6 +18,7 @@ interface State {
destroying: boolean
rollback?: () => void
releaseContainer?: () => void
mountingTemplate?: LifecycleTemplate
}
const states = new WeakMap<object, State>()
@@ -48,6 +54,20 @@ export function isClosing(owner: object): boolean {
return state.destroying || state.scope.closed
}
// The proxy callback runs before the public art.template assignment. Keep its
// in-progress template reachable by teardown without changing that public order.
export function duringTemplateMount(owner: object, template: LifecycleTemplate, initialize: () => void): void {
const state = stateOf(owner)
const previous = state.mountingTemplate
state.mountingTemplate = template
try {
initialize()
}
finally {
state.mountingTemplate = previous
}
}
export function ownContainer(owner: object, container: Element, rollback: () => void): void {
const current = containers.get(container)
if (current && current !== owner)
@@ -84,7 +104,7 @@ export function destroyInstance(owner: LifecycleOwner, instances: LifecycleOwner
if (removeSource && owner.template?.$video)
attempt(() => owner.reset())
attempt(() => state.scope.dispose())
attempt(() => owner.template?.destroy(removeHtml))
attempt(() => (owner.template || state.mountingTemplate)?.destroy(removeHtml))
const index = instances.indexOf(owner)
if (index !== -1)
instances.splice(index, 1)
+6 -7
View File
@@ -1,15 +1,13 @@
import type { CoreEmission } from '../../events/core-types'
import type { NoticeSink } from '../../notice'
export type MediaEventName = `video:${string}`
export type MediaListener = (event?: unknown) => void
export interface MediaEvents {
export interface MediaEvents extends CoreEmission<MediaEventName | 'ready' | 'resize' | 'error'> {
on: (name: MediaEventName, callback: MediaListener) => unknown
once: (name: MediaEventName, callback: MediaListener) => unknown
off: (name: MediaEventName, callback: MediaListener) => unknown
emit: {
(name: MediaEventName, event: Event): unknown
(name: 'ready' | 'resize'): unknown
(name: 'error', error: unknown, attempt: number): unknown
}
}
export interface MediaUI {
@@ -28,11 +26,12 @@ export interface MediaEventHost extends MediaEvents, MediaUI {
isReady: boolean
readonly playing: boolean
url: string | null
get seek(): undefined
set seek(time: number)
play: () => unknown
option: { url: string, loop: boolean }
i18n: { get: (key: string) => string }
notice: { set show(message: string) }
notice: NoticeSink
constructor: { RECONNECT_TIME_MAX: number, RECONNECT_SLEEP_TIME: number }
}
+2 -1
View File
@@ -1,3 +1,4 @@
import type { NoticeSink } from '../notice'
import type { ResolvedOption } from '../option/types'
import type { PlaybackMethods } from './types'
@@ -7,7 +8,7 @@ export interface MediaHost<Media> {
interface NoticeHost {
i18n: { get: (key: string) => string }
notice: { get show(): unknown, set show(value: string | Error) }
notice: NoticeSink
emit: (name: 'play' | 'pause') => unknown
}
+6 -3
View File
@@ -1,3 +1,6 @@
import type { PipProperty } from '../display/types'
import type { NoticeSink } from '../notice'
export interface Position {
readonly duration: number
get currentTime(): number
@@ -14,13 +17,13 @@ export interface SeekMethods {
}
export interface SeekHost extends Position {
notice: { set show(message: string) }
notice: NoticeSink
emit: (name: 'seek', currentTime: number, requestedTime: number | string) => unknown
}
export interface PlaybackNotice {
i18n: { get: (key: string) => string }
notice: { set show(message: string) }
notice: NoticeSink
}
export interface VolumeHost extends PlaybackNotice {
@@ -30,7 +33,7 @@ export interface VolumeHost extends PlaybackNotice {
}
export type DisplayState = 'mini' | 'pip' | 'fullscreen' | 'fullscreenWeb'
export type DisplayStates = Record<DisplayState, boolean>
export type DisplayStates = Record<'mini' | 'fullscreenWeb', boolean> & PipProperty & { fullscreen?: boolean }
export interface StateProperty {
get state(): DisplayState | 'standard'
+6 -1
View File
@@ -2,6 +2,11 @@ import type { SubscriptionHost } from './component/resources'
import { getScope, isClosing } from './lifecycle/instance'
import { addClass, removeClass } from './utils'
export interface NoticeSink {
get show(): unknown
set show(value: unknown)
}
interface NoticeHost extends SubscriptionHost<{ destroy: [] }> {
constructor: { NOTICE_TIME: number }
template: { $player: HTMLElement, $noticeInner: HTMLElement }
@@ -24,7 +29,7 @@ function cancelNotice(notice: Notice): void {
}
}
export default class Notice {
export default class Notice implements NoticeSink {
declare art: NoticeHost
declare timer: ReturnType<typeof setTimeout> | null
+28
View File
@@ -0,0 +1,28 @@
import type { ContextmenuHost } from '../contextmenu/types'
import type { ControlHost } from '../control/types'
import type { LayerHost } from '../layer'
import type { MediaSurface } from '../media/types'
import type { PluginFactory } from '../plugins/types'
import type { SettingItem } from '../setting/types'
import type { UrlHost } from '../source/types'
import type { OptionInput, ResolvedOption } from './types'
import resolveOption from './resolve'
interface RuntimeCallbacks<Host> {
proxy?: (this: Host, art: Host) => unknown
plugins: PluginFactory<Host>[]
customType: UrlHost<MediaSurface, Host>['option']['customType']
layers: LayerHost['option']['layers']
controls: ControlHost['option']['controls']
contextmenu: ContextmenuHost['option']['contextmenu']
settings: SettingItem[]
}
export type RuntimeOption<Host> = Omit<ResolvedOption, keyof RuntimeCallbacks<Host>> & RuntimeCallbacks<Host>
export default function resolveRuntimeOption<Host>(input: OptionInput, defaults: ResolvedOption): RuntimeOption<Host> {
// The external declaration facade and internal module views describe the same
// caller-owned callbacks. Do not wrap, clone or rebind their runtime identity.
// All non-callback options still derive directly from the validated merge type.
return resolveOption(input, defaults) as unknown as RuntimeOption<Host>
}
@@ -1,40 +0,0 @@
import { def } from '../utils'
export default function airplayMix(art) {
const {
i18n,
notice,
proxy,
template: { $video },
} = art
let available = true
if (window.WebKitPlaybackTargetAvailabilityEvent && $video.webkitShowPlaybackTargetPicker) {
proxy($video, 'webkitplaybacktargetavailabilitychanged', (event) => {
switch (event.availability) {
case 'available':
available = true
break
case 'not-available':
available = false
break
}
})
}
else {
available = false
}
def(art, 'airplay', {
value() {
if (available) {
$video.webkitShowPlaybackTargetPicker()
art.emit('airplay')
}
else {
notice.show = i18n.get('AirPlay Not Available')
}
},
})
}
@@ -0,0 +1,70 @@
import type { NoticeSink } from '../notice'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export interface AirplayHost {
template: { $video: EventTarget & { webkitShowPlaybackTargetPicker?: () => void } }
i18n: { get: (key: string) => string }
notice: NoticeSink
proxy: (target: EventTarget, name: string, listener: (event: Event & { availability?: string }) => void) => unknown
emit: (name: 'airplay') => unknown
}
export default function airplayMix(art: AirplayHost): void {
if (isClosing(art))
return
const {
i18n,
notice,
proxy,
template: { $video },
} = art
let available = true
const supported = (window as Window & { WebKitPlaybackTargetAvailabilityEvent?: unknown }).WebKitPlaybackTargetAvailabilityEvent
if (isClosing(art))
return
const picker = supported && $video.webkitShowPlaybackTargetPicker
if (isClosing(art))
return
if (picker) {
proxy($video, 'webkitplaybacktargetavailabilitychanged', (event) => {
if (isClosing(art))
return
switch (event.availability) {
case 'available':
available = true
break
case 'not-available':
available = false
break
}
})
}
else {
available = false
}
if (isClosing(art))
return
def(art, 'airplay', {
value() {
if (isClosing(art))
return
if (available) {
const picker = $video.webkitShowPlaybackTargetPicker
if (isClosing(art))
return
Reflect.apply(picker!, $video, [])
if (!isClosing(art))
art.emit('airplay')
}
else {
const message = i18n.get('AirPlay Not Available')
if (!isClosing(art))
notice.show = message
}
},
})
}
-15
View File
@@ -1,15 +0,0 @@
import { def } from '../utils'
export default function attrMix(art) {
const {
template: { $video },
} = art
def(art, 'attr', {
value(key, value) {
if (value === undefined)
return $video[key]
$video[key] = value
},
})
}
+24
View File
@@ -0,0 +1,24 @@
import { def } from '../utils'
export interface AttributeMethods {
attr: {
(key: PropertyKey): unknown
(key: PropertyKey, value: unknown): unknown
}
}
export default function attrMix(art: { template: { $video: object } }): void {
const {
template: { $video },
} = art
def(art, 'attr', {
value(key: PropertyKey, value?: unknown): unknown {
// attr intentionally exposes arbitrary native/proxy properties, including symbols.
const target = $video as Record<PropertyKey, unknown>
if (value === undefined)
return target[key]
target[key] = value
},
})
}
@@ -1,16 +0,0 @@
import { def } from '../utils'
export default function cssVarMix(art) {
const { $player } = art.template
def(art, 'cssVar', {
value(key, value) {
if (value) {
return $player.style.setProperty(key, value)
}
else {
return getComputedStyle($player).getPropertyValue(key)
}
},
})
}
@@ -0,0 +1,24 @@
import { def } from '../utils'
export interface CssVariableMethods {
cssVar: {
(key: string): string
(key: string, value: unknown): string | void
}
}
export default function cssVarMix(art: { template: { $player: HTMLElement } }): void {
const { $player } = art.template
def(art, 'cssVar', {
value(key: string, value?: unknown): string | void {
if (value) {
// Retain Web IDL conversion and the historical truthy write condition.
return $player.style.setProperty(key, value as string)
}
else {
return getComputedStyle($player).getPropertyValue(key)
}
},
})
}
-75
View File
@@ -1,75 +0,0 @@
import airplayMix from './airplayMix'
import aspectRatioMix from './aspectRatioMix'
import attrMix from './attrMix'
import autoHeightMix from './autoHeightMix'
import autoSizeMix from './autoSizeMix'
import cssVarMix from './cssVarMix'
import currentTimeMix from './currentTimeMix'
import durationMix from './durationMix'
import eventInit from './eventInit'
import flipMix from './flipMix'
import fullscreenMix from './fullscreenMix'
import fullscreenWebMix from './fullscreenWebMix'
import loadedMix from './loadedMix'
import miniMix from './miniMix'
import optionInit from './optionInit'
import pauseMix from './pauseMix'
import pipMix from './pipMix'
import playbackRateMix from './playbackRateMix'
import playedMix from './playedMix'
import playingMix from './playingMix'
import playMix from './playMix'
import posterMix from './posterMix'
import qualityMix from './qualityMix'
import rectMix from './rectMix'
import screenshotMix from './screenshotMix'
import seekMix from './seekMix'
import stateMix from './stateMix'
import subtitleOffsetMix from './subtitleOffsetMix'
import switchMix from './switchMix'
import themeMix from './themeMix'
import thumbnailsMix from './thumbnailsMix'
import toggleMix from './toggleMix'
import typeMix from './typeMix'
import urlMix from './urlMix'
import volumeMix from './volumeMix'
export default class Player {
constructor(art) {
urlMix(art)
attrMix(art)
playMix(art)
pauseMix(art)
toggleMix(art)
seekMix(art)
volumeMix(art)
currentTimeMix(art)
durationMix(art)
switchMix(art)
playbackRateMix(art)
aspectRatioMix(art)
screenshotMix(art)
fullscreenMix(art)
fullscreenWebMix(art)
pipMix(art)
loadedMix(art)
playedMix(art)
playingMix(art)
autoSizeMix(art)
rectMix(art)
flipMix(art)
miniMix(art)
posterMix(art)
autoHeightMix(art)
cssVarMix(art)
themeMix(art)
typeMix(art)
stateMix(art)
subtitleOffsetMix(art)
airplayMix(art)
qualityMix(art)
thumbnailsMix(art)
eventInit(art)
optionInit(art)
}
}
+128
View File
@@ -0,0 +1,128 @@
import type { MediaSurface } from '../media/types'
import type { UrlHost } from '../source/types'
import { isClosing } from '../lifecycle/instance'
import airplayMix from './airplayMix'
import aspectRatioMix from './aspectRatioMix'
import attrMix from './attrMix'
import autoHeightMix from './autoHeightMix'
import autoSizeMix from './autoSizeMix'
import cssVarMix from './cssVarMix'
import currentTimeMix from './currentTimeMix'
import durationMix from './durationMix'
import eventInit from './eventInit'
import flipMix from './flipMix'
import fullscreenMix from './fullscreenMix'
import fullscreenWebMix from './fullscreenWebMix'
import loadedMix from './loadedMix'
import miniMix from './miniMix'
import optionInit from './optionInit'
import pauseMix from './pauseMix'
import pipMix from './pipMix'
import playbackRateMix from './playbackRateMix'
import playedMix from './playedMix'
import playingMix from './playingMix'
import playMix from './playMix'
import posterMix from './posterMix'
import qualityMix from './qualityMix'
import rectMix from './rectMix'
import screenshotMix from './screenshotMix'
import seekMix from './seekMix'
import stateMix from './stateMix'
import subtitleOffsetMix from './subtitleOffsetMix'
import switchMix from './switchMix'
import themeMix from './themeMix'
import thumbnailsMix from './thumbnailsMix'
import toggleMix from './toggleMix'
import typeMix from './typeMix'
import urlMix from './urlMix'
import volumeMix from './volumeMix'
// Installation requirements come from the owning modules. Some properties are
// installed here and consumed only by later callbacks, after construction.
type PlayerDependencies = Parameters<typeof airplayMix>[0]
& Parameters<typeof aspectRatioMix>[0]
& Parameters<typeof attrMix>[0]
& Parameters<typeof autoHeightMix>[0]
& Parameters<typeof autoSizeMix>[0]
& Parameters<typeof cssVarMix>[0]
& Parameters<typeof currentTimeMix>[0]
& Parameters<typeof durationMix>[0]
& Parameters<typeof eventInit>[0]
& Parameters<typeof flipMix>[0]
& Parameters<typeof fullscreenMix>[0]
& Parameters<typeof fullscreenWebMix>[0]
& Parameters<typeof loadedMix>[0]
& Parameters<typeof miniMix>[0]
& Parameters<typeof optionInit>[0]
& Parameters<typeof pauseMix>[0]
& Parameters<typeof pipMix>[0]
& Parameters<typeof playbackRateMix>[0]
& Parameters<typeof playedMix>[0]
& Parameters<typeof playingMix>[0]
& Parameters<typeof playMix>[0]
& Parameters<typeof posterMix>[0]
& Parameters<typeof qualityMix>[0]
& Parameters<typeof rectMix>[0]
& Parameters<typeof screenshotMix>[0]
& Parameters<typeof seekMix>[0]
& Parameters<typeof stateMix>[0]
& Parameters<typeof subtitleOffsetMix>[0]
& Parameters<typeof switchMix>[0]
& Parameters<typeof themeMix>[0]
& Parameters<typeof thumbnailsMix>[0]
& Parameters<typeof toggleMix>[0]
& Parameters<typeof typeMix>[0]
& Parameters<typeof volumeMix>[0]
// The order is observable through descriptors, subscriptions and option writes.
const installers: readonly ((art: PlayerDependencies) => void)[] = [
attrMix,
playMix,
pauseMix,
toggleMix,
seekMix,
volumeMix,
currentTimeMix,
durationMix,
switchMix,
playbackRateMix,
aspectRatioMix,
screenshotMix,
fullscreenMix,
fullscreenWebMix,
pipMix,
loadedMix,
playedMix,
playingMix,
autoSizeMix,
rectMix,
flipMix,
miniMix,
posterMix,
autoHeightMix,
cssVarMix,
themeMix,
typeMix,
stateMix,
subtitleOffsetMix,
airplayMix,
qualityMix,
thumbnailsMix,
eventInit,
optionInit,
]
export type PlayerHost<Host> = UrlHost<MediaSurface, Host> & PlayerDependencies
export default class Player<Host extends PlayerHost<Host>> {
constructor(art: Host) {
if (isClosing(art))
return
urlMix<MediaSurface, Host>(art)
for (const install of installers) {
if (isClosing(art))
return
install(art)
}
}
}
@@ -1,49 +0,0 @@
import { clamp, setStyle } from '../utils'
export default function optionInit(art) {
const {
option,
storage,
template: { $video, $poster },
} = art
for (const key in option.moreVideoAttr) {
art.attr(key, option.moreVideoAttr[key])
}
if (option.muted) {
art.muted = option.muted
}
if (option.volume) {
$video.volume = clamp(option.volume, 0, 1)
}
const volumeStorage = storage.get('volume')
if (typeof volumeStorage === 'number') {
$video.volume = clamp(volumeStorage, 0, 1)
}
if (option.poster) {
setStyle($poster, 'backgroundImage', `url(${option.poster})`)
}
if (option.autoplay) {
$video.autoplay = option.autoplay
}
if (option.playsInline) {
$video.playsInline = true
$video['webkit-playsinline'] = true
}
if (option.theme) {
option.cssVar['--art-theme'] = option.theme
}
for (const key in option.cssVar) {
art.cssVar(key, option.cssVar[key])
}
art.url = option.url
}
+117
View File
@@ -0,0 +1,117 @@
import type { ResolvedOption } from '../option/types'
import type { AttributeMethods } from './attrMix'
import type { CssVariableMethods } from './cssVarMix'
import { isClosing } from '../lifecycle/instance'
import { clamp, setStyle } from '../utils'
export interface InitialOptionHost extends AttributeMethods, CssVariableMethods {
option: Pick<ResolvedOption, 'moreVideoAttr' | 'muted' | 'volume' | 'poster' | 'autoplay' | 'playsInline' | 'theme' | 'cssVar' | 'url'>
storage: { get: (key: string) => unknown }
template: {
$video: Pick<HTMLMediaElement, 'volume'> & { 'autoplay'?: boolean, 'playsInline'?: boolean, 'webkit-playsinline'?: boolean }
$poster: HTMLElement
}
muted: boolean
get url(): string | null
set url(value: string)
}
export default function optionInit(art: InitialOptionHost): void {
if (isClosing(art))
return
const {
option,
storage,
template: { $video, $poster },
} = art
for (const key in option.moreVideoAttr) {
if (isClosing(art))
return
const value = (option.moreVideoAttr as Record<string, unknown>)[key]
if (isClosing(art))
return
art.attr(key, value)
}
if (isClosing(art))
return
if (option.muted) {
const value = option.muted
if (isClosing(art))
return
art.muted = value
}
if (isClosing(art))
return
if (option.volume) {
const value = clamp(option.volume, 0, 1)
if (isClosing(art))
return
$video.volume = value
}
if (isClosing(art))
return
const volumeStorage = storage.get('volume')
if (isClosing(art))
return
if (typeof volumeStorage === 'number') {
$video.volume = clamp(volumeStorage, 0, 1)
}
if (isClosing(art))
return
if (option.poster) {
const value = `url(${option.poster})`
if (isClosing(art))
return
setStyle($poster, 'backgroundImage', value)
}
if (isClosing(art))
return
if (option.autoplay) {
const value = option.autoplay
if (isClosing(art))
return
$video.autoplay = value
}
if (isClosing(art))
return
if (option.playsInline) {
if (isClosing(art))
return
$video.playsInline = true
if (isClosing(art))
return
$video['webkit-playsinline'] = true
}
if (isClosing(art))
return
if (option.theme) {
const styles = option.cssVar
const theme = option.theme
if (isClosing(art))
return
styles['--art-theme'] = theme
}
for (const key in option.cssVar) {
if (isClosing(art))
return
const value = (option.cssVar as Record<string, unknown>)[key]
if (isClosing(art))
return
art.cssVar(key, value)
}
if (isClosing(art))
return
const url = option.url
if (!isClosing(art))
art.url = url
}
@@ -1,6 +1,6 @@
import { def, setStyle } from '../utils'
export default function posterMix(art) {
export default function posterMix(art: { template: { $poster: HTMLElement } }): void {
const {
template: { $poster },
} = art
@@ -8,13 +8,13 @@ export default function posterMix(art) {
def(art, 'poster', {
get: () => {
try {
return $poster.style.backgroundImage.match(/"(.*)"/)[1]
return $poster.style.backgroundImage.match(/"(.*)"/)![1]!
}
catch {
return ''
}
},
set(url) {
set(url: string) {
setStyle($poster, 'backgroundImage', `url(${url})`)
},
})
@@ -0,0 +1,43 @@
import type { Thumbnails } from '../../types/option'
import type { QualityItem } from '../control/types'
import type { PipProperty } from '../display/types'
import type { RectState } from '../media/hosts'
import type { Position, SeekMethods, StateProperty } from '../media/playback'
import type { SwitchMethods } from '../source/types'
import type { AttributeMethods } from './attrMix'
import type { CssVariableMethods } from './cssVarMix'
// Runtime descriptors installed by Player, distinct from each installer's inputs.
// Declaring these on the entry must never emit class fields before installation.
export interface PlayerProperties extends AttributeMethods, CssVariableMethods, Position, SeekMethods, SwitchMethods, RectState, PipProperty, StateProperty {
get url(): string | null
set url(value: string)
play: () => Promise<unknown>
pause: () => unknown
toggle: () => unknown
volume: number
muted: boolean
playbackRate: number
aspectRatio: string
flip: string
fullscreen?: boolean
fullscreenWeb: boolean
mini: boolean
readonly loaded: number
readonly loadedTime: number
readonly played: number
readonly playing: boolean
autoSize: () => void
autoHeight: () => void
poster: string
theme: string
type: string
subtitleOffset: number
airplay: () => void
get quality(): undefined
set quality(value: QualityItem[])
thumbnails: Thumbnails
getDataURL: () => Promise<string>
getBlobUrl: () => Promise<string>
screenshot: (name?: string) => Promise<string>
}
+5 -4
View File
@@ -1,12 +1,13 @@
import type { ComponentHost, EntryOption } from '../component/types'
import type { QualityItem } from '../control/types'
import type { ComponentHost } from '../component/types'
import type { ControlOption, QualityItem } from '../control/types'
import type { NoticeSink } from '../notice'
import { captureSelection } from '../component/selection'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export interface QualityHost extends ComponentHost {
controls: { update: (option: EntryOption<QualityHost>) => unknown }
notice: { set show(message: string) }
controls: { update: (option: ControlOption) => unknown }
notice: NoticeSink
i18n: { get: (key: string) => string }
switchQuality: (url: string) => Promise<unknown>
}
@@ -1,4 +1,5 @@
import type { CaptureMedia } from '../capture/frame'
import type { NoticeSink } from '../notice'
import { captureBlobUrl, drawFrame } from '../capture/frame'
import { isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
@@ -6,7 +7,7 @@ import { def, download, secondToTime } from '../utils'
export interface ScreenshotHost {
template: { $video: CaptureMedia }
notice: { set show(value: unknown) }
notice: NoticeSink
getDataURL: () => Promise<string>
emit: (name: 'screenshot', dataUri: string) => unknown
}
@@ -1,11 +1,12 @@
import type { CssVariableMethods } from './cssVarMix'
import { def } from '../utils'
export default function themeMix(art) {
export default function themeMix(art: CssVariableMethods): void {
def(art, 'theme', {
get() {
return art.cssVar('--art-theme')
},
set(theme) {
set(theme: string) {
art.cssVar('--art-theme', theme)
},
})
@@ -1,11 +1,11 @@
import { def } from '../utils'
export default function typeMix(art) {
export default function typeMix(art: { option: { type: string } }): void {
def(art, 'type', {
get() {
return art.option.type
},
set(type) {
set(type: string) {
art.option.type = type
},
})
@@ -24,7 +24,8 @@ export interface ResumePromptHost extends ComponentHost, SubscriptionHost<Prompt
i18n: { get: (key: string) => string }
storage: PlaybackStorage
option: { id?: string, url: string }
seek: number
get seek(): undefined
set seek(value: number)
play: () => unknown
}
@@ -5,7 +5,7 @@ import { isClosing } from '../../lifecycle/instance'
export type PlaybackTimes = Record<string, number>
export interface PlaybackStorage {
get: (key: 'times') => PlaybackTimes | null | undefined
get: (key: 'times') => unknown
set: (key: 'times', value: PlaybackTimes) => void
del: (key: 'times') => void
}
@@ -19,7 +19,9 @@ export interface PlaybackRecordHost extends SubscriptionHost<{ 'video:timeupdate
}
export function readTimes(storage: PlaybackStorage): PlaybackTimes {
return storage.get('times') || {}
// Historical storage is unvalidated JSON. Keep its truthy fallback and localize
// the legacy record assumption to this consumer, rather than all Storage reads.
return (storage.get('times') || {}) as PlaybackTimes
}
export function installPlaybackRecords(art: PlaybackRecordHost): void {
+14 -10
View File
@@ -1,4 +1,4 @@
import type { BuiltinRegistry, PluginFactory, PluginHost } from './types'
import type { BuiltinRegistry, PluginHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import autoOrientation from './autoOrientation'
@@ -7,18 +7,22 @@ import fastForward from './fastForward'
import lock from './lock'
import miniProgressBar from './miniProgressBar'
export default function installBuiltins<Host extends PluginHost<Host>>(registry: BuiltinRegistry<Host>, option: PluginHost<Host>['option']): void {
export type BuiltinHost = Parameters<typeof miniProgressBar>[0]
& Parameters<typeof lock>[0]
& Parameters<typeof autoPlayback>[0]
& Parameters<typeof autoOrientation>[0]
& Parameters<typeof fastForward>[0]
export default function installBuiltins<Host extends PluginHost<Host> & BuiltinHost>(registry: BuiltinRegistry<Host>, option: PluginHost<Host>['option']): void {
// Read each condition at its original turn; earlier plugins may change options.
if (!isClosing(registry.art) && option.miniProgressBar && !option.isLive)
registry.add(miniProgressBar as unknown as PluginFactory<Host>)
registry.add(miniProgressBar)
if (!isClosing(registry.art) && option.lock && isMobile)
registry.add(lock as unknown as PluginFactory<Host>)
registry.add(lock)
if (!isClosing(registry.art) && option.autoPlayback && !option.isLive)
registry.add(autoPlayback as unknown as PluginFactory<Host>)
if (!isClosing(registry.art) && option.autoOrientation && isMobile) {
// The JS constructor supplies the complete host before builtin installation.
registry.add(autoOrientation as unknown as PluginFactory<Host>)
}
registry.add(autoPlayback)
if (!isClosing(registry.art) && option.autoOrientation && isMobile)
registry.add(autoOrientation)
if (!isClosing(registry.art) && option.fastForward && isMobile && !option.isLive)
registry.add(fastForward as unknown as PluginFactory<Host>)
registry.add(fastForward)
}
+2 -1
View File
@@ -1,10 +1,11 @@
import type { BuiltinHost } from './builtins'
import type { PluginFactory, PluginHost, PluginRegistration } from './types'
import { isClosing } from '../lifecycle/instance'
import { errorHandle } from '../utils/error'
import installBuiltins from './builtins'
import registerPlugin from './registration'
export default class Plugins<Host extends PluginHost<Host>> {
export default class Plugins<Host extends PluginHost<Host> & BuiltinHost> {
declare art: Host
declare id: number
+7 -6
View File
@@ -1,5 +1,6 @@
import type { SettingKind } from './selection'
import type { SettingItem, SettingManager, SettingRange } from './types'
import { appendElement } from '../component/dom'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { addClass, append, def, has, inverseClass, setStyle } from '../utils'
@@ -38,7 +39,7 @@ export function createSettingHeader(setting: SettingManager, item: SettingItem):
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 $left = appendElement($item, '<div class="art-setting-item-left"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
@@ -88,8 +89,8 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
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 $left = appendElement($item, '<div class="art-setting-item-left"></div>')
const $right = appendElement($item, '<div class="art-setting-item-right"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
@@ -149,8 +150,8 @@ export function createSettingItem(setting: SettingManager, item: SettingItem, is
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 $switchOn = appendElement($switch, icons.switchOn)
const $switchOff = appendElement($switch, icons.switchOff)
const initialSwitch = item.switch
if (!current())
return
@@ -308,7 +309,7 @@ export function renderSetting(setting: SettingManager, option = setting.option):
const restoreNavigation = () => {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
inverseClass(setting.cache.get(previous)!, 'art-current')
for (const [key, value, priority] of previousLayout)
setting.$parent.style.setProperty(key, value, priority)
}
+2 -2
View File
@@ -37,7 +37,7 @@ export interface SettingEvents {
'blur': []
'focus': [event: Event]
'resize': []
'video:ratechange': []
'video:ratechange': [Event]
'aspectRatio': [value: string]
'flip': [value: string]
'subtitleOffset': [value: number]
@@ -48,7 +48,7 @@ export interface SettingHost extends ComponentHost, SubscriptionHost<SettingEven
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>
icons: Record<'arrowLeft' | 'arrowRight' | 'config' | 'check' | 'switchOn' | 'switchOff' | 'playbackRate' | 'aspectRatio' | 'flip', HTMLElement> & { subtitle?: HTMLElement }
proxy: ComponentHost['events']['proxy']
i18n: { get: (key: string) => string }
setting: SettingManager
+1 -1
View File
@@ -155,7 +155,7 @@ export function updateSetting(setting: SettingManager, target: SettingItem): Set
if (rendered) {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
inverseClass(setting.cache.get(previous)!, 'art-current')
for (const [key, value, priority] of layout)
setting.$parent.style.setProperty(key, value, priority)
}
+4 -2
View File
@@ -1,5 +1,7 @@
import type { NoticeSink } from '../notice'
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay' | 'video:seeked' | 'seek'
export type SourceListener = (event?: unknown) => void
export type SourceListener = (event?: unknown, ...extra: unknown[]) => void
export interface SourceEvents {
on: (name: SourceEvent, callback: SourceListener) => unknown
@@ -35,7 +37,7 @@ export interface SwitchHost extends UrlTarget, SourceEvents {
playbackRate: number
pause: () => unknown
play: () => unknown
notice: { set show(value: string) }
notice: NoticeSink
}
export interface SwitchMethods {
@@ -1,12 +1,16 @@
export default class Storage {
declare name: string
declare settings: Record<PropertyKey, unknown>
constructor() {
this.name = 'artplayer_settings'
this.settings = {}
}
get(key) {
get(key?: PropertyKey): unknown {
try {
const storage = JSON.parse(window.localStorage.getItem(this.name)) || {}
// Preserve JSON.parse(null), primitive payloads and truthy-key selection.
const storage = (JSON.parse(window.localStorage.getItem(this.name) as string) || {}) as Record<PropertyKey, unknown>
return key ? storage[key] : storage
}
catch {
@@ -14,7 +18,7 @@ export default class Storage {
}
}
set(key, value) {
set(key: PropertyKey, value: unknown): void {
try {
const storage = Object.assign({}, this.get(), {
[key]: value,
@@ -26,9 +30,9 @@ export default class Storage {
}
}
del(key) {
del(key: PropertyKey): void {
try {
const storage = this.get()
const storage = this.get() as Record<PropertyKey, unknown>
delete storage[key]
window.localStorage.setItem(this.name, JSON.stringify(storage))
}
@@ -37,7 +41,7 @@ export default class Storage {
}
}
clear() {
clear(): void {
try {
window.localStorage.removeItem(this.name)
}
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Subtitle as SubtitleInput } from '../../types/subtitle'
import type { ComponentHost } from '../component/types'
import type { OptionalMediaCapabilities } from '../media/types'
import type { NoticeSink } from '../notice'
export type SubtitleOption = Required<SubtitleInput>
@@ -20,7 +21,7 @@ export interface SubtitleHost extends ComponentHost {
proxy: ComponentHost['events']['proxy']
on: (name: 'video:timeupdate', callback: () => void) => unknown
off: (name: 'video:timeupdate', callback: () => void) => unknown
notice: { set show(value: unknown) }
notice: NoticeSink
i18n: { get: (key: string) => string }
}
@@ -38,7 +39,7 @@ export interface SubtitleOffsetHost {
template: { $track?: SubtitleTrack | null }
subtitle: { readonly cues: SubtitleCue[], update: () => void }
readonly duration: number
notice: { set show(value: string) }
notice: NoticeSink
i18n: { get: (key: string) => string }
emit: (name: 'subtitleOffset', value: number) => unknown
}
+11 -7
View File
@@ -1,5 +1,5 @@
import type { TemplateHost, TemplateNodes } from './template/types'
import { ownContainer } from './lifecycle/instance'
import { duringTemplateMount, isClosing, ownContainer } from './lifecycle/instance'
import captureTemplate from './lifecycle/template-rollback'
import html from './template/html'
import bindNodes from './template/nodes'
@@ -56,8 +56,10 @@ export default class Template<Host extends TemplateHost<Host>> implements Templa
this.query = this.query.bind(this)
ownContainer(art, this.$container, captureTemplate(this.$container))
this.$container.dataset.artId = String(art.id)
this.init()
duringTemplateMount(art, this, () => {
this.$container.dataset.artId = String(art.id)
this.init()
})
}
static get html(): string {
@@ -79,18 +81,20 @@ export default class Template<Host extends TemplateHost<Host>> implements Templa
if (option.proxy) {
const video = option.proxy.call(this.art, this.art)
if (isClosing(this.art))
return
assertProxy(video)
replaceElement(video, this.$video)
replaceElement(video, this.$video!)
video.className = 'art-video'
this.$video = video
}
if (option.backdrop) {
addClass(this.$player, 'art-backdrop')
addClass(this.$player!, 'art-backdrop')
}
if (isMobile) {
addClass(this.$player, 'art-mobile')
addClass(this.$player!, 'art-mobile')
}
}
@@ -99,7 +103,7 @@ export default class Template<Host extends TemplateHost<Host>> implements Templa
this.$container.innerHTML = ''
}
else {
addClass(this.$player, 'art-destroy')
addClass(this.$player!, 'art-destroy')
}
}
}
+13
View File
@@ -1,3 +1,16 @@
import type { MediaSurface } from '../media/types'
import type Template from '../template'
// Consumers of the canonical template require these nodes. SSR callers supply
// the same structure and proxy callers supply a media-like element; neither
// contract is proven by a DOM query. Keep this assertion at the entry boundary.
// Template itself and all public query helpers retain nullable query results.
type RequiredPlayerNode = Exclude<keyof TemplateNodes, '$danmuku' | '$controlsCenter' | '$notice' | '$mask' | '$info'>
export type PlayerTemplate<Host extends TemplateHost<Host>> = Template<Host> & {
[Name in RequiredPlayerNode]: NonNullable<TemplateNodes[Name]>
} & { $video: MediaSurface, $mini?: HTMLElement }
export interface TemplateHost<Host> {
id: number
option: {
@@ -1,6 +0,0 @@
export const userAgent = globalThis?.CUSTOM_USER_AGENT ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
export const isSafari = /^(?:(?!chrome|android).)*safari/i.test(userAgent)
export const isIOS = /iPad|iPhone|iPod/i.test(userAgent) && !window.MSStream
export const isIOS13 = isIOS || (userAgent.includes('Macintosh') && navigator.maxTouchPoints >= 1)
export const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || isIOS13
export const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
@@ -0,0 +1,7 @@
const customAgent = (globalThis as { CUSTOM_USER_AGENT?: string }).CUSTOM_USER_AGENT
export const userAgent = customAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent)
export const isSafari = /^(?:(?!chrome|android).)*safari/i.test(userAgent)
export const isIOS = /iPad|iPhone|iPod/i.test(userAgent) && (typeof window === 'undefined' || !(window as Window & { MSStream?: unknown }).MSStream)
export const isIOS13 = isIOS || (userAgent.includes('Macintosh') && typeof navigator !== 'undefined' && navigator.maxTouchPoints >= 1)
export const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || isIOS13
export const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
-145
View File
@@ -1,145 +0,0 @@
import { isMobile } from './compatibility'
export { loadImg } from '../image/load'
export { setStyleText } from '../style/inject'
/** @param {string} selector @param {ParentNode} parent */
export function query(selector, parent = document) {
return parent.querySelector(selector)
}
export function queryAll(selector, parent = document) {
return Array.from(parent.querySelectorAll(selector))
}
export function addClass(target, className) {
return target.classList.add(className)
}
export function removeClass(target, className) {
return target.classList.remove(className)
}
export function hasClass(target, className) {
return target.classList.contains(className)
}
export function append(parent, child) {
if (child instanceof Element) {
parent.appendChild(child)
}
else {
parent.insertAdjacentHTML('beforeend', String(child))
}
return parent.lastElementChild || parent.lastChild
}
export function remove(child) {
return child.parentNode.removeChild(child)
}
export function setStyle(element, key, value) {
element.style[key] = value
return element
}
export function setStyles(element, styles) {
for (const key in styles) {
setStyle(element, key, styles[key])
}
return element
}
export function getStyle(element, key, numberType = true) {
const value = window.getComputedStyle(element, null).getPropertyValue(key)
return numberType ? Number.parseFloat(value) : value
}
export function siblings(target) {
return Array.from(target.parentElement.children).filter(item => item !== target)
}
export function inverseClass(target, className) {
siblings(target).forEach(item => removeClass(item, className))
addClass(target, className)
}
export function tooltip(target, msg, pos = 'top') {
if (isMobile)
return
target.setAttribute('aria-label', msg)
addClass(target, 'hint--rounded')
addClass(target, `hint--${pos}`)
}
export function isInViewport(el, offset = 0) {
const rect = el.getBoundingClientRect()
const windowHeight = window.innerHeight || document.documentElement.clientHeight
const windowWidth = window.innerWidth || document.documentElement.clientWidth
const vertInView = rect.top - offset <= windowHeight && rect.top + rect.height + offset >= 0
const horInView = rect.left - offset <= windowWidth + offset && rect.left + rect.width + offset >= 0
return vertInView && horInView
}
export function includeFromEvent(event, target) {
return getComposedPath(event).includes(target)
}
export function replaceElement(newChild, oldChild) {
oldChild.parentNode.replaceChild(newChild, oldChild)
return newChild
}
export function createElement(tag) {
return document.createElement(tag)
}
/** @param {string} key @param {string | HTMLElement} html */
export function getIcon(key = '', html = '') {
const icon = createElement('i')
addClass(icon, 'art-icon')
addClass(icon, `art-icon-${key}`)
append(icon, html)
return icon
}
export function supportsFlex() {
const div = document.createElement('div')
div.style.display = 'flex'
return div.style.display === 'flex'
}
export function getRect(el) {
return el.getBoundingClientRect()
}
export function getComposedPath(event) {
if (event.composedPath)
return event.composedPath()
const path = []
let node = event.target
while (node) {
path.push(node)
node = node.parentNode
}
if (!path.includes(window) && window !== undefined) {
path.push(window)
}
return path
}
export function getSafeAreaInsets() {
const div = document.createElement('div')
div.style.cssText
= 'position:fixed;top:env(safe-area-inset-top,0px);right:env(safe-area-inset-right,0px);bottom:env(safe-area-inset-bottom,0px);left:env(safe-area-inset-left,0px);pointer-events:none;visibility:hidden;'
document.body.appendChild(div)
const style = getComputedStyle(div)
const insets = {
top: Number.parseFloat(style.top) || 0,
right: Number.parseFloat(style.right) || 0,
bottom: Number.parseFloat(style.bottom) || 0,
left: Number.parseFloat(style.left) || 0,
}
div.remove()
return insets
}
+7
View File
@@ -0,0 +1,7 @@
export { getComposedPath, includeFromEvent } from '../dom/event-path'
export { getRect, getSafeAreaInsets, isInViewport, supportsFlex } from '../dom/measure'
export { getIcon, tooltip } from '../dom/presentation'
export { getStyle, setStyle, setStyles } from '../dom/styles'
export { addClass, append, createElement, hasClass, inverseClass, query, queryAll, remove, removeClass, replaceElement, siblings } from '../dom/tree'
export { loadImg } from '../image/load'
export { setStyleText } from '../style/inject'