refactor(core): CORE-17 type input events and own interaction lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 11:24:15 +08:00
1 parent 88aef830df
commit a842058136
72 files changed
+31156 -1219

No files matched your search

@@ -1,8 +1,15 @@
import type { ControlHost } from '../types'
import { clamp, getRect, isMobile, secondToTime } from '../../utils'
export type ProgressEvent = MouseEvent | TouchEvent
type ProgressPositionHost = Pick<ControlHost, 'template' | 'duration' | 'isRotate' | 'top' | 'height' | 'emit' | 'seek'>
export interface ProgressPositionHost {
template: { $progress: HTMLElement }
duration: number
isRotate: boolean
top: number
height: number
seek: number | string
emit: (name: 'setBar', kind: 'played', percentage: number, event: ProgressEvent) => unknown
}
export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
const { $progress } = art.template
@@ -15,16 +22,18 @@ export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent)
return { second, time, width, percentage }
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent, active: () => boolean = () => true) {
if (art.isRotate) {
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
const second = percentage * art.duration
art.emit('setBar', 'played', percentage, event)
art.seek = second
if (active())
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
if (active())
art.seek = second
}
}
@@ -1,65 +0,0 @@
import { includeFromEvent, isMobile, silencePromise } from '../utils'
export default function clickInit(art, events) {
const {
constructor,
template: { $player, $video },
} = art
function onDocumentClick(event) {
if (includeFromEvent(event, $player)) {
art.isInput = event.target.tagName === 'INPUT'
art.isFocus = true
art.emit('focus', event)
}
else {
art.isInput = false
art.isFocus = false
art.emit('blur', event)
}
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
let clickTimes = []
events.proxy($video, 'click', (event) => {
const now = Date.now()
clickTimes.push(now)
const { MOBILE_CLICK_PLAY, DBCLICK_TIME, MOBILE_DBCLICK_PLAY, DBCLICK_FULLSCREEN } = constructor
const clicks = clickTimes.filter(t => now - t <= DBCLICK_TIME)
switch (clicks.length) {
case 1:
art.emit('click', event)
if (isMobile) {
if (!art.isLock && MOBILE_CLICK_PLAY) {
silencePromise(art.toggle())
}
}
else {
silencePromise(art.toggle())
}
clickTimes = clicks
break
case 2:
art.emit('dblclick', event)
if (isMobile) {
if (!art.isLock && MOBILE_DBCLICK_PLAY) {
silencePromise(art.toggle())
}
}
else {
if (DBCLICK_FULLSCREEN) {
art.fullscreen = !art.fullscreen
}
}
clickTimes = []
break
default:
clickTimes = []
}
})
}
@@ -0,0 +1,45 @@
import type { ClickHost, PointerRegistry } from '../input/pointer-types'
import { pointerFocus } from '../input/pointer-focus'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import { silencePromise } from '../utils/error'
export default function clickInit(art: ClickHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { constructor, template: { $video } } = art
pointerFocus(art)
let clickTimes: number[] = []
events.proxy($video, 'click', (event) => {
if (isClosing(art))
return
const now = Date.now()
clickTimes.push(now)
const { MOBILE_CLICK_PLAY, DBCLICK_TIME, MOBILE_DBCLICK_PLAY, DBCLICK_FULLSCREEN } = constructor
const clicks = clickTimes.filter(t => now - t <= DBCLICK_TIME)
switch (clicks.length) {
case 1:
art.emit('click', event)
if (isClosing(art))
return
if (!isMobile || (!art.isLock && MOBILE_CLICK_PLAY))
silencePromise(art.toggle())
clickTimes = clicks
break
case 2:
art.emit('dblclick', event)
if (isClosing(art))
return
if (isMobile) {
if (!art.isLock && MOBILE_DBCLICK_PLAY)
silencePromise(art.toggle())
}
else if (DBCLICK_FULLSCREEN) {
art.fullscreen = !art.fullscreen
}
clickTimes = []
break
default:
clickTimes = []
}
})
}
@@ -1,104 +0,0 @@
import { setCurrentTime } from '../control/progress'
import { clamp, isMobile, secondToTime } from '../utils'
function GetSlideAngle(dx, dy) {
return (Math.atan2(dy, dx) * 180) / Math.PI
}
function GetSlideDirection(startX, startY, endX, endY) {
const dy = startY - endY
const dx = endX - startX
let result = 0
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) {
return result
}
const angle = GetSlideAngle(dx, dy)
if (angle >= -45 && angle < 45) {
result = 4
}
else if (angle >= 45 && angle < 135) {
result = 1
}
else if (angle >= -135 && angle < -45) {
result = 2
}
else if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135)) {
result = 3
}
return result
}
export default function gestureInit(art, events) {
if (isMobile && !art.option.isLive) {
const { $video, $progress } = art.template
let touchTarget = null
let isDragging = false
let startX = 0
let startY = 0
let startTime = 0
const onTouchStart = (event) => {
if (event.touches.length === 1 && !art.isLock) {
if (touchTarget === $progress) {
setCurrentTime(art, event)
}
isDragging = true
const { pageX, pageY } = event.touches[0]
startX = pageX
startY = pageY
startTime = art.currentTime
}
}
const onTouchMove = (event) => {
if (event.touches.length === 1 && isDragging && art.duration) {
const { pageX, pageY } = event.touches[0]
const direction = GetSlideDirection(startX, startY, pageX, pageY)
const isHorizontal = [3, 4].includes(direction)
const isVertical = [1, 2].includes(direction)
const isLegal = (isHorizontal && !art.isRotate) || (isVertical && art.isRotate)
if (isLegal) {
const ratioX = clamp((pageX - startX) / art.width, -1, 1)
const ratioY = clamp((pageY - startY) / art.height, -1, 1)
const ratio = art.isRotate ? ratioY : ratioX
const TOUCH_MOVE_RATIO = touchTarget === $video ? art.constructor.TOUCH_MOVE_RATIO : 1
const currentTime = clamp(startTime + art.duration * ratio * TOUCH_MOVE_RATIO, 0, art.duration)
art.seek = currentTime
art.emit('setBar', 'played', clamp(currentTime / art.duration, 0, 1), event)
art.notice.show = `${secondToTime(currentTime)} / ${secondToTime(art.duration)}`
}
}
}
const onTouchEnd = () => {
if (isDragging) {
startX = 0
startY = 0
startTime = 0
isDragging = false
touchTarget = null
}
}
if (art.option.gesture) {
events.proxy($video, 'touchstart', (event) => {
touchTarget = $video
onTouchStart(event)
})
events.proxy($video, 'touchmove', onTouchMove)
}
events.proxy($progress, 'touchstart', (event) => {
touchTarget = $progress
onTouchStart(event)
})
events.proxy($progress, 'touchmove', onTouchMove)
art.on('document:touchend', onTouchEnd)
}
}
@@ -0,0 +1,19 @@
import type { GestureHost } from '../input/gesture-types'
import type { PointerRegistry } from '../input/pointer-types'
import { gestureController } from '../input/gesture-controller'
import { isMobile } from '../utils/compatibility'
export default function gestureInit(art: GestureHost, events: Pick<PointerRegistry, 'proxy'>): void {
if (!isMobile || art.option.isLive)
return
const { $video, $progress } = art.template
const gesture = gestureController(art)
const bind = (target: EventTarget) => {
events.proxy(target, 'touchstart', event => gesture.start(target, event as TouchEvent))
events.proxy(target, 'touchmove', event => gesture.move(event as TouchEvent))
events.proxy(target, 'touchcancel', gesture.cancel)
}
if (art.option.gesture)
bind($video)
bind($progress)
}
@@ -0,0 +1,17 @@
import type { Disposer, Listener } from './listener-registry'
export interface GlobalEventSource {
document?: Document
window?: Window
}
export interface GlobalEventHost {
template: { $player: HTMLElement }
emit: (name: string, event: Event) => unknown
}
export interface GlobalEventRegistry {
proxy: (target: EventTarget, name: string, listener: Listener) => Disposer
remove: (dispose: Disposer) => void
bindGlobalEvents?: (source?: GlobalEventSource) => void
}
@@ -1,52 +0,0 @@
export default function globalInit(art, events) {
const documentEvents = [
'click',
'mouseup',
'keydown',
'touchend',
'touchmove',
'mousemove',
'pointerup',
'contextmenu',
'pointermove',
'visibilitychange',
'webkitfullscreenchange',
]
const windowEvents = [
'resize',
'scroll',
'orientationchange',
]
const destroyEvents = []
function bindGlobalEvents(source = {}) {
for (let index = 0; index < destroyEvents.length; index++) {
events.remove(destroyEvents[index])
}
destroyEvents.length = 0
const { $player } = art.template
documentEvents.forEach((name) => {
const doc = source.document || $player.ownerDocument || document
const destroy = events.proxy(doc, name, (event) => {
art.emit(`document:${name}`, event)
})
destroyEvents.push(destroy)
})
windowEvents.forEach((name) => {
const win = source.window || $player.ownerDocument?.defaultView || window
const destroy = events.proxy(win, name, (event) => {
art.emit(`window:${name}`, event)
})
destroyEvents.push(destroy)
})
}
bindGlobalEvents()
events.bindGlobalEvents = bindGlobalEvents
}
@@ -0,0 +1,80 @@
import type { GlobalEventHost, GlobalEventRegistry, GlobalEventSource } from './global-types'
import type { Disposer } from './listener-registry'
import { getScope, isClosing } from '../lifecycle/instance'
const documentEvents = [
'click',
'mouseup',
'keydown',
'touchend',
'touchcancel',
'touchmove',
'mousemove',
'pointerup',
'contextmenu',
'pointermove',
'visibilitychange',
'webkitfullscreenchange',
]
const windowEvents = ['resize', 'scroll', 'orientationchange']
interface Binding {
disposers: Disposer[]
}
export default function globalInit(art: GlobalEventHost, events: GlobalEventRegistry): void {
let active: Binding | undefined
let generation = 0
getScope(art).add(() => {
active = undefined
generation++
return undefined
})
const release = (binding: Binding | undefined) => {
if (binding) {
for (const dispose of binding.disposers)
events.remove(dispose)
binding.disposers.length = 0
}
}
function bindGlobalEvents(source: GlobalEventSource = {}): void {
if (isClosing(art))
return
const current = ++generation
const binding: Binding = { disposers: [] }
const cancelled = () => current !== generation || isClosing(art)
try {
const { $player } = art.template
const doc = source.document || $player.ownerDocument || document
const win = source.window || $player.ownerDocument?.defaultView || window
const register = (target: EventTarget, names: string[], prefix: string) => {
for (const name of names) {
if (cancelled())
return
binding.disposers.push(events.proxy(target, name, (event) => {
if (active === binding && !isClosing(art))
art.emit(`${prefix}:${name}`, event)
}))
}
}
register(doc, documentEvents, 'document')
register(win, windowEvents, 'window')
}
catch (error) {
release(binding)
throw error
}
if (cancelled()) {
release(binding)
return
}
const previous = active
active = binding
release(previous)
}
bindGlobalEvents()
events.bindGlobalEvents = bindGlobalEvents
}
@@ -1,17 +0,0 @@
import { addClass, removeClass } from '../utils'
export default function hoverInit(art, events) {
const { $player } = art.template
events.hover(
$player,
(event) => {
addClass($player, 'art-hover')
art.emit('hover', true, event)
},
(event) => {
removeClass($player, 'art-hover')
art.emit('hover', false, event)
},
)
}
@@ -0,0 +1,22 @@
import type { HoverHost, PointerRegistry } from '../input/pointer-types'
import { isClosing } from '../lifecycle/instance'
import { addClass, removeClass } from '../utils/dom'
export default function hoverInit(art: HoverHost, events: Pick<PointerRegistry, 'hover'>): void {
const { $player } = art.template
events.hover(
$player,
(event) => {
if (isClosing(art))
return
addClass($player, 'art-hover')
art.emit('hover', true, event)
},
(event) => {
if (isClosing(art))
return
removeClass($player, 'art-hover')
art.emit('hover', false, event)
},
)
}
-80
View File
@@ -1,80 +0,0 @@
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
import hoverInit from './hoverInit'
import moveInit from './moveInit'
import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
const scopes = new WeakMap()
export default class Events {
constructor(art) {
this.destroyEvents = new Set()
const scope = getScope(art)
scopes.set(this, scope)
scope.add(() => this.destroy())
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
clickInit(art, this)
hoverInit(art, this)
moveInit(art, this)
resizeInit(art, this)
gestureInit(art, this)
viewInit(art, this)
globalInit(art, this)
updateInit(art, this)
}
proxy(target, name, callback, option = {}) {
if (Array.isArray(name)) {
return name.map(item => this.proxy(target, item, callback, option))
}
if (scopes.get(this).closed)
return () => {}
target.addEventListener(name, callback, option)
const destroy = () => target.removeEventListener(name, callback, option)
this.destroyEvents.add(destroy)
return destroy
}
hover(target, mouseenter, mouseleave) {
if (mouseenter) {
this.proxy(target, 'mouseenter', mouseenter)
}
if (mouseleave) {
this.proxy(target, 'mouseleave', mouseleave)
}
}
remove(destroyEvent) {
if (this.destroyEvents.has(destroyEvent)) {
try {
destroyEvent()
}
catch (error) {
console.warn('Failed to remove event listener:', error)
}
finally {
this.destroyEvents.delete(destroyEvent)
}
}
}
destroy() {
for (const destroyEvent of this.destroyEvents) {
try {
destroyEvent()
}
catch (error) {
console.warn('Failed to destroy event listener:', error)
}
}
this.destroyEvents.clear()
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { GlobalEventSource } from './global-types'
import type { Disposer, Listener } from './listener-registry'
import type { EventsHost } from './types'
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
import hoverInit from './hoverInit'
import { destroyListeners, ownListeners, proxyListener, removeListener } from './listener-registry'
import moveInit from './moveInit'
import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
export default class Events {
declare destroyEvents: Set<Disposer>
declare bindGlobalEvents: (source?: GlobalEventSource) => void
constructor(art: EventsHost) {
this.destroyEvents = new Set()
ownListeners(this, art)
getScope(art).add(() => {
this.destroy()
})
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
clickInit(art, this)
hoverInit(art, this)
moveInit(art, this)
resizeInit(art, this)
gestureInit(art, this)
viewInit(art)
globalInit(art, this)
updateInit(art)
}
proxy(target: EventTarget, name: string, callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer
proxy(target: EventTarget, name: string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer[]
proxy(target: EventTarget, name: string | string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer | Disposer[]
proxy(target: EventTarget, name: string | string[], callback: Listener, option: boolean | AddEventListenerOptions | null = {}): Disposer | Disposer[] {
return Array.isArray(name) ? proxyListener(this, target, name, callback, option) : proxyListener(this, target, name, callback, option)
}
hover(target: EventTarget, mouseenter?: (event: Event) => unknown, mouseleave?: (event: Event) => unknown): void {
if (mouseenter)
this.proxy(target, 'mouseenter', mouseenter)
if (mouseleave)
this.proxy(target, 'mouseleave', mouseleave)
}
remove(dispose: Disposer): void {
removeListener(this, dispose)
}
destroy(): void {
destroyListeners(this)
}
}
@@ -0,0 +1,135 @@
import type ResourceScope from '../lifecycle/scope'
import { getScope } from '../lifecycle/instance'
export type Disposer = () => void
export type Listener = EventListenerOrEventListenerObject | null
export interface ListenerRegistry {
destroyEvents: Set<Disposer>
}
interface State {
scope: ResourceScope
cleaning: boolean
}
const states = new WeakMap<ListenerRegistry, State>()
export function ownListeners(registry: ListenerRegistry, art: object): void {
states.set(registry, { scope: getScope(art), cleaning: false })
}
function stateOf(registry: ListenerRegistry): State {
const state = states.get(registry)
if (!state)
throw new Error('ArtPlayer event registry has not been initialized')
return state
}
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer[]
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string, callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string | string[], callback: Listener, option: boolean | AddEventListenerOptions | null = {}): Disposer | Disposer[] {
if (Array.isArray(names)) {
const created: Disposer[] = []
try {
return names.map((name) => {
const dispose = proxyListener(registry, target, name, callback, option)
created.push(dispose)
return dispose
})
}
catch (error) {
for (const dispose of created.reverse()) {
try {
dispose()
}
catch (cleanupError) {
console.warn('Failed to roll back event listener:', cleanupError)
}
}
throw error
}
}
const name = names
const state = stateOf(registry)
if (state.scope.closed || state.cleaning)
return () => {}
// Snapshot native dictionary values, especially capture, without wrapping the callback.
const options = typeof option === 'boolean'
? option
: {
capture: Boolean(option?.capture),
once: Boolean(option?.once),
passive: option?.passive,
signal: option?.signal,
}
const capture = typeof options === 'boolean' ? options : options.capture
const signal = typeof options === 'boolean' ? undefined : options.signal
if (state.scope.closed || state.cleaning || signal?.aborted)
return () => {}
let active = true
function clean(force = false): void {
if (!active && !force)
return
target.removeEventListener(name, callback, capture)
signal?.removeEventListener('abort', dispose)
active = false
registry.destroyEvents.delete(dispose)
}
function dispose(): void {
clean()
}
registry.destroyEvents.add(dispose)
try {
target.addEventListener(name, callback, options)
// A custom target may synchronously destroy the player before adding the listener.
if (!active || state.scope.closed || state.cleaning)
clean(true)
else if (signal?.aborted)
dispose()
else
signal?.addEventListener('abort', dispose, { once: true })
}
catch (error) {
try {
clean(true)
}
catch (cleanupError) {
console.warn('Failed to roll back event listener:', cleanupError)
}
throw error
}
return dispose
}
export function removeListener(registry: ListenerRegistry, dispose: Disposer): void {
if (!registry.destroyEvents.has(dispose))
return
try {
dispose()
registry.destroyEvents.delete(dispose)
}
catch (error) {
// Retain a failed removal so a later remove/destroy can retry it.
console.warn('Failed to remove event listener:', error)
}
}
export function destroyListeners(registry: ListenerRegistry): void {
const state = stateOf(registry)
if (state.cleaning)
return
state.cleaning = true
try {
for (const dispose of registry.destroyEvents) {
try {
dispose()
registry.destroyEvents.delete(dispose)
}
catch (error) {
console.warn('Failed to destroy event listener:', error)
}
}
}
finally {
state.cleaning = false
}
}
@@ -1,7 +0,0 @@
export default function moveInit(art, events) {
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
art.emit('mousemove', event)
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { MoveHost, PointerRegistry } from '../input/pointer-types'
import { isClosing } from '../lifecycle/instance'
export default function moveInit(art: MoveHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
if (!isClosing(art))
art.emit('mousemove', event)
})
}
@@ -1,29 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
export default function resizeInit(art, events) {
const { option, constructor } = art
art.on('resize', () => {
const { aspectRatio, notice } = art
if (art.state === 'standard' && option.autoSize) {
art.autoSize()
}
art.aspectRatio = aspectRatio
notice.show = ''
})
const scope = getScope(art)
let cancel = () => {}
const resizeFn = () => {
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
art.on('window:orientationchange', () => resizeFn())
art.on('window:resize', () => resizeFn())
if (screen && screen.orientation && screen.orientation.onchange) {
events.proxy(screen.orientation, 'change', () => resizeFn())
}
}
@@ -0,0 +1,35 @@
import type { PointerRegistry } from '../input/pointer-types'
import type { ResizeEvents, ResizeHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
export default function resizeInit(art: ResizeHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { option, constructor } = art
const on = eventSubscriptions<ResizeEvents>(art)
on('resize', () => {
const { aspectRatio, notice } = art
if (art.state === 'standard' && option.autoSize)
art.autoSize()
if (isClosing(art))
return
art.aspectRatio = aspectRatio
if (!isClosing(art))
notice.show = ''
})
const scope = getScope(art)
let cancel = () => {}
const resize = () => {
if (isClosing(art))
return
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
on('window:orientationchange', resize)
on('window:resize', resize)
const orientation = art.template.$player.ownerDocument.defaultView?.screen?.orientation
if (typeof orientation?.addEventListener === 'function')
events.proxy(orientation, 'change', resize)
}
@@ -0,0 +1,34 @@
import type { SubscriptionHost } from '../component/resources'
export interface ResizeEvents {
'resize': []
'window:resize': [Event]
'window:orientationchange': [Event]
}
export interface ResizeHost extends SubscriptionHost<ResizeEvents> {
template: { $player: HTMLElement }
option: { autoSize: boolean }
constructor: { RESIZE_TIME: number }
aspectRatio: string
state: string
autoSize: () => unknown
notice: { show: string }
emit: (name: 'resize') => unknown
}
export interface ViewEvents { 'window:scroll': [Event], 'view': [boolean] }
export interface ViewHost extends SubscriptionHost<ViewEvents> {
template: { $container: HTMLElement }
option: { autoMini: boolean }
constructor: { SCROLL_GAP: number, SCROLL_TIME: number }
mini: boolean
emit: (name: 'view', visible: boolean) => unknown
}
export interface RafEvents { destroy: [] }
export interface RafHost extends SubscriptionHost<RafEvents> {
constructor: { USE_RAF: boolean }
playing: boolean
emit: (name: 'raf') => unknown
}
@@ -0,0 +1,17 @@
import type { SubscriptionHost } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
export function eventSubscriptions<Events extends { [Name in keyof Events]: readonly unknown[] }>(art: SubscriptionHost<Events>) {
return <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown): void => {
if (isClosing(art))
return
const listener = (...args: [...Events[Name]]) => {
if (!isClosing(art))
return callback(...args)
}
art.on(name, listener)
getScope(art).add(() => {
art.off(name, listener)
})
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { GestureHost } from '../input/gesture-types'
import type { ClickHost, HoverHost, MoveHost } from '../input/pointer-types'
import type { GlobalEventHost } from './global-types'
import type { RafHost, ResizeHost, ViewHost } from './scheduling-types'
export type EventsHost = ClickHost & HoverHost & MoveHost & GestureHost & GlobalEventHost & RafHost & ResizeHost & ViewHost
@@ -1,23 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
export default function updateInit(art) {
if (art.constructor.USE_RAF) {
const scope = getScope(art)
let cancel = () => {};
(function update() {
if (art.playing) {
art.emit('raf')
}
if (!art.isDestroy) {
cancel = animationFrame(scope, update)
}
})()
art.on('destroy', () => {
cancel()
})
}
}
@@ -0,0 +1,21 @@
import type { RafEvents, RafHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
export default function updateInit(art: RafHost): void {
if (!art.constructor.USE_RAF)
return
const scope = getScope(art)
let cancel = () => {}
const update = () => {
if (isClosing(art))
return
if (art.playing)
art.emit('raf')
if (!isClosing(art))
cancel = animationFrame(scope, update)
}
update()
eventSubscriptions<RafEvents>(art)('destroy', () => cancel())
}
-21
View File
@@ -1,21 +0,0 @@
import { isInViewport, throttle } from '../utils'
export default function viewInit(art) {
const {
option,
constructor,
template: { $container },
} = art
const scrollFn = throttle(() => {
art.emit('view', isInViewport($container, constructor.SCROLL_GAP))
}, constructor.SCROLL_TIME)
art.on('window:scroll', () => scrollFn())
art.on('view', (state) => {
if (option.autoMini) {
art.mini = !state
}
})
}
+29
View File
@@ -0,0 +1,29 @@
import type { ViewEvents, ViewHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
import { inViewport } from './viewport'
export default function viewInit(art: ViewHost): void {
const { option, constructor, template: { $container } } = art
const delay = constructor.SCROLL_TIME
const scope = getScope(art)
const on = eventSubscriptions<ViewEvents>(art)
let waiting = false
on('window:scroll', () => {
if (waiting)
return
art.emit('view', inViewport($container, constructor.SCROLL_GAP))
if (isClosing(art))
return
// Keep the leading-only throttle and its historical synchronous reentry timing.
waiting = true
timeout(scope, () => {
waiting = false
}, delay)
})
on('view', (visible) => {
if (option.autoMini)
art.mini = !visible
})
}
+11
View File
@@ -0,0 +1,11 @@
export function inViewport(element: HTMLElement, offset: number): boolean {
const rect = element.getBoundingClientRect()
const document = element.ownerDocument
const window = document.defaultView
const height = window?.innerHeight || document.documentElement.clientHeight
const width = window?.innerWidth || document.documentElement.clientWidth
// Retain the historical edge/gap calculations while using the actual owner viewport.
const vertical = rect.top - offset <= height && rect.top + rect.height + offset >= 0
const horizontal = rect.left - offset <= width + offset && rect.left + rect.width + offset >= 0
return vertical && horizontal
}
-96
View File
@@ -1,96 +0,0 @@
import { isMobile, silencePromise } from './utils'
export default class Hotkey {
constructor(art) {
this.art = art
this.keys = {}
if (!isMobile) {
this.init()
}
}
init() {
const { constructor } = this.art
if (this.art.option.hotkey) {
this.add('Escape', () => {
if (this.art.fullscreenWeb) {
this.art.fullscreenWeb = false
}
})
this.add('Space', () => {
silencePromise(this.art.toggle())
})
this.add('ArrowLeft', () => {
this.art.backward = constructor.SEEK_STEP
})
this.add('ArrowUp', () => {
this.art.volume += constructor.VOLUME_STEP
})
this.add('ArrowRight', () => {
this.art.forward = constructor.SEEK_STEP
})
this.add('ArrowDown', () => {
this.art.volume -= constructor.VOLUME_STEP
})
}
this.art.on('document:keydown', (event) => {
if (this.art.isFocus) {
const tag = document.activeElement.tagName.toUpperCase()
const editable = document.activeElement.getAttribute('contenteditable')
if (
tag !== 'INPUT'
&& tag !== 'TEXTAREA'
&& editable !== ''
&& editable !== 'true'
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
&& !event.shiftKey
) {
const events = this.keys[event.code]
if (events) {
event.preventDefault()
for (let index = 0; index < events.length; index++) {
events[index].call(this.art, event)
}
this.art.emit('hotkey', event)
}
}
}
this.art.emit('keydown', event)
})
}
add(key, event) {
if (this.keys[key]) {
if (!this.keys[key].includes(event)) {
this.keys[key].push(event)
}
}
else {
this.keys[key] = [event]
}
return this
}
remove(key, event) {
if (this.keys[key]) {
const index = this.keys[key].indexOf(event)
if (index !== -1) {
this.keys[key].splice(index, 1)
}
if (this.keys[key].length === 0) {
delete this.keys[key]
}
}
return this
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { HotkeyCallback, HotkeyHost } from './input/hotkey-types'
import { defaultHotkeys } from './input/hotkey-defaults'
import { acceptsHotkey } from './input/keyboard-focus'
import { getScope, isClosing } from './lifecycle/instance'
import { isMobile } from './utils/compatibility'
interface State {
defaults?: () => void
subscribed: boolean
}
const states = new WeakMap<object, State>()
export default class Hotkey<Host extends HotkeyHost> {
declare art: Host
declare keys: Record<string, HotkeyCallback<Host>[]>
constructor(art: Host) {
this.art = art
this.keys = {}
states.set(this, { subscribed: false })
if (!isMobile)
this.init()
}
init(): void {
const art = this.art
if (isClosing(art))
return
const state = states.get(this)!
if (art.option.hotkey) {
if (!state.defaults) {
const callbacks = defaultHotkeys(art)
state.defaults = () => {
for (const key of Object.keys(callbacks))
this.add(key, callbacks[key]!)
}
}
state.defaults()
}
if (state.subscribed)
return
state.subscribed = true
const onKeydown = (event: KeyboardEvent) => {
if (isClosing(art))
return
if (art.isFocus && acceptsHotkey(event, art.template.$player.ownerDocument)) {
const callbacks = Object.prototype.hasOwnProperty.call(this.keys, event.code) ? this.keys[event.code] : undefined
if (callbacks) {
event.preventDefault()
// Keep the historical live-array mutation and callback receiver contracts.
for (let index = 0; index < callbacks.length; index++) {
if (isClosing(art))
return
callbacks[index]!.call(art, event)
}
if (isClosing(art))
return
art.emit('hotkey', event)
}
}
if (!isClosing(art))
art.emit('keydown', event)
}
art.on('document:keydown', onKeydown)
getScope(art).add(() => {
state.subscribed = false
art.off('document:keydown', onKeydown)
})
}
add(key: string, callback: HotkeyCallback<Host>): this {
const existing = Object.prototype.hasOwnProperty.call(this.keys, key) ? this.keys[key] : undefined
if (existing) {
if (!existing.includes(callback))
existing.push(callback)
}
else {
Object.defineProperty(this.keys, key, { value: [callback], enumerable: true, configurable: true, writable: true })
}
return this
}
remove(key: string, callback: HotkeyCallback<Host>): this {
const existing = Object.prototype.hasOwnProperty.call(this.keys, key) ? this.keys[key] : undefined
if (existing) {
const index = existing.indexOf(callback)
if (index !== -1)
existing.splice(index, 1)
if (existing.length === 0)
delete this.keys[key]
}
return this
}
}
@@ -0,0 +1,94 @@
import type { Drag, GestureHost } from './gesture-types'
import { setCurrentTime } from '../control/progress/position'
import { getScope, isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { clamp, secondToTime } from '../utils'
import { slideDirection } from './gesture-direction'
export function gestureController(art: GestureHost) {
let drag: Drag | undefined
const cancel = () => {
drag = undefined
}
const active = (current: Drag) => drag === current && !isClosing(art) && !art.isLock
&& !art.option.isLive && art.isRotate === current.rotated && current.sourceActive()
const onLock = (locked: boolean) => {
if (locked)
cancel()
}
art.on('document:touchend', cancel)
art.on('document:touchcancel', cancel)
art.on('lock', onLock)
getScope(art).add(() => {
cancel()
art.off('document:touchend', cancel)
art.off('document:touchcancel', cancel)
art.off('lock', onLock)
})
const start = (target: EventTarget, event: TouchEvent) => {
cancel()
const touch = event.touches[0]
if (isClosing(art) || art.isLock || art.option.isLive || event.touches.length !== 1 || !touch)
return
if (![touch.pageX, touch.pageY, touch.clientX, touch.clientY, art.currentTime].every(Number.isFinite))
return
const current: Drag = {
target,
identifier: touch.identifier,
x: touch.pageX,
y: touch.pageY,
time: art.currentTime,
rotated: art.isRotate,
sourceActive: captureSource(art),
}
drag = current
if (target === art.template.$progress) {
const size = art.isRotate ? art.height : art.template.$progress.clientWidth
if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(art.duration) || art.duration <= 0) {
cancel()
return
}
try {
setCurrentTime(art, event, () => active(current))
}
catch (error) {
if (drag === current)
cancel()
throw error
}
if (active(current))
current.time = art.currentTime
}
}
const move = (event: TouchEvent) => {
const current = drag
const touch = event.touches[0]
if (!current)
return
if (!active(current) || event.touches.length !== 1 || !touch || touch.identifier !== current.identifier) {
cancel()
return
}
const size = current.rotated ? art.height : art.width
const multiplier = current.target === art.template.$video ? art.constructor.TOUCH_MOVE_RATIO : 1
if (![touch.pageX, touch.pageY, size, art.duration, multiplier].every(Number.isFinite) || size <= 0 || art.duration <= 0) {
cancel()
return
}
const direction = slideDirection(current.x, current.y, touch.pageX, touch.pageY)
if (!(current.rotated ? direction === 1 || direction === 2 : direction === 3 || direction === 4))
return
const distance = current.rotated ? touch.pageY - current.y : touch.pageX - current.x
const ratio = clamp(distance / size, -1, 1)
const time = clamp(current.time + art.duration * ratio * multiplier, 0, art.duration)
art.seek = time
if (!active(current))
return
art.emit('setBar', 'played', clamp(time / art.duration, 0, 1), event)
if (active(current))
art.notice.show = `${secondToTime(time)} / ${secondToTime(art.duration)}`
}
return { start, move, cancel }
}
@@ -0,0 +1,16 @@
export function slideDirection(startX: number, startY: number, endX: number, endY: number): 0 | 1 | 2 | 3 | 4 {
const dy = startY - endY
const dx = endX - startX
if (Math.abs(dx) < 2 && Math.abs(dy) < 2)
return 0
const angle = Math.atan2(dy, dx) * 180 / Math.PI
if (angle >= -45 && angle < 45)
return 4
if (angle >= 45 && angle < 135)
return 1
if (angle >= -135 && angle < -45)
return 2
if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135))
return 3
return 0
}
@@ -0,0 +1,26 @@
import type { SubscriptionHost } from '../component/resources'
import type { ProgressPositionHost } from '../control/progress/position'
export interface GestureHost extends ProgressPositionHost, SubscriptionHost<{
'document:touchend': [Event]
'document:touchcancel': [Event]
'lock': [boolean]
}> {
template: { $video: EventTarget, $progress: HTMLElement }
option: { isLive: boolean, gesture: boolean }
constructor: { TOUCH_MOVE_RATIO: number }
isLock: boolean
width: number
currentTime: number
notice: { show: string }
}
export interface Drag {
target: EventTarget
identifier: number
x: number
y: number
time: number
rotated: boolean
sourceActive: () => boolean
}
@@ -0,0 +1,27 @@
import type { HotkeyCallback, HotkeyHost } from './hotkey-types'
import { silencePromise } from '../utils/error'
export function defaultHotkeys<Host extends HotkeyHost>(art: Host): Record<string, HotkeyCallback<Host>> {
const { constructor } = art
return {
Escape: () => {
if (art.fullscreenWeb)
art.fullscreenWeb = false
},
Space: () => {
silencePromise(art.toggle())
},
ArrowLeft: () => {
art.backward = constructor.SEEK_STEP
},
ArrowUp: () => {
art.volume += constructor.VOLUME_STEP
},
ArrowRight: () => {
art.forward = constructor.SEEK_STEP
},
ArrowDown: () => {
art.volume -= constructor.VOLUME_STEP
},
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface HotkeyHost extends SubscriptionHost<{ 'document:keydown': [KeyboardEvent] }> {
constructor: { SEEK_STEP: number, VOLUME_STEP: number }
option: { hotkey?: boolean }
template: { $player: HTMLElement }
isFocus: boolean
fullscreenWeb: boolean
backward: number
forward: number
volume: number
toggle: () => unknown
emit: (name: 'hotkey' | 'keydown', event: KeyboardEvent) => unknown
}
export type HotkeyCallback<Host> = (this: Host, event: KeyboardEvent) => unknown
@@ -0,0 +1,30 @@
function asElement(target: EventTarget | null | undefined): HTMLElement | undefined {
if (target && (target as Node).nodeType === 1)
return target as HTMLElement
}
function eventDocument(event: KeyboardEvent, fallback: Document): Document {
const target = event.target as Node | null
return target?.nodeType === 9 ? target as Document : target?.ownerDocument || event.view?.document || fallback
}
function editable(target: HTMLElement): boolean {
const tag = target.tagName.toUpperCase()
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
return true
return target.isContentEditable
}
export function acceptsHotkey(event: KeyboardEvent, fallback: Document): boolean {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing || event.keyCode === 229)
return false
const doc = eventDocument(event, fallback)
let active = asElement(doc.activeElement)
while (active?.shadowRoot?.activeElement)
active = asElement(active.shadowRoot.activeElement)
if (active && editable(active))
return false
// The composed path exposes an editable descendant behind a retargeted shadow host.
const first = asElement(event.composedPath?.()[0]) || asElement(event.target)
return !first || !editable(first)
}
@@ -0,0 +1,21 @@
import type { FocusHost } from './pointer-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function pointerFocus(art: FocusHost): void {
const { $player } = art.template
const onDocumentClick = (event: Event) => {
if (isClosing(art))
return
const inside = includeFromEvent(event, $player)
art.isInput = inside && (event.target as Element | null)?.tagName === 'INPUT'
art.isFocus = inside
art.emit(inside ? 'focus' : 'blur', event)
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
getScope(art).add(() => {
art.off('document:click', onDocumentClick)
art.off('document:contextmenu', onDocumentClick)
})
}
@@ -0,0 +1,39 @@
import type { SubscriptionHost } from '../component/resources'
import type { Disposer, Listener } from '../events/listener-registry'
export interface PointerRegistry {
proxy: (target: EventTarget, name: string, callback: Listener) => Disposer
hover: (target: EventTarget, enter?: (event: Event) => unknown, leave?: (event: Event) => unknown) => void
}
export interface FocusHost extends SubscriptionHost<{ 'document:click': [Event], 'document:contextmenu': [Event] }> {
template: { $player: HTMLElement }
isInput: boolean
isFocus: boolean
emit: (name: 'focus' | 'blur', event: Event) => unknown
}
export interface ClickHost extends FocusHost {
constructor: {
MOBILE_CLICK_PLAY: boolean
MOBILE_DBCLICK_PLAY: boolean
DBCLICK_TIME: number
DBCLICK_FULLSCREEN: boolean
}
template: { $player: HTMLElement, $video: EventTarget }
isLock: boolean
fullscreen: boolean
toggle: () => unknown
emit: (name: 'focus' | 'blur' | 'click' | 'dblclick', event: Event) => unknown
}
export interface HoverHost {
template: { $player: HTMLElement }
emit: (name: 'hover', state: boolean, event: Event) => unknown
}
export interface MoveHost {
template: { $player: HTMLElement }
emit: (name: 'mousemove', event: Event) => unknown
}