refactor(core): CORE-13 type component registries and own control lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 04:54:32 +08:00
1 parent 9eb7c945ca
commit 697817eceb
68 files changed
+10100 -1925

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
import { append } from '../utils'
export default function airplay(option) {
return art => ({
...option,
tooltip: art.i18n.get('AirPlay'),
mounted: ($control) => {
const { proxy, icons } = art
append($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
})
}
+16
View File
@@ -0,0 +1,16 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { controlEvents } from './resources'
export default function airplay(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('AirPlay'),
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
appendElement($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
})
}
+135
View File
@@ -0,0 +1,135 @@
import type { EntryInput } from '../component/types'
import type { ControlHost } from './types'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { isMobile } from '../utils'
import airplay from './airplay'
import fullscreen from './fullscreen'
import fullscreenWeb from './fullscreenWeb'
import pip from './pip'
import playAndPause from './playAndPause'
import progress from './progress'
import screenshot from './screenshot'
import setting from './setting'
import time from './time'
import volume from './volume'
export function installControls(controls: { art: ControlHost, add: (option: EntryInput<ControlHost>) => undefined }): void {
const { option } = controls.art
if (!option.isLive) {
controls.add(
progress({
name: 'progress',
position: 'top',
index: 10,
}),
)
}
controls.add({
name: 'thumbnails',
position: 'top',
index: 20,
})
controls.add(
playAndPause({
name: 'playAndPause',
position: 'left',
index: 10,
}),
)
controls.add(
volume({
name: 'volume',
position: 'left',
index: 20,
}),
)
if (!option.isLive) {
controls.add(
time({
name: 'time',
position: 'left',
index: 30,
}),
)
}
if (option.quality.length) {
wait(getScope(controls.art)).then((active) => {
if (!active || getScope(controls.art).closed)
return
controls.art.quality = option.quality
}).catch((error) => {
console.warn('ArtPlayer quality initialization failed:', error)
})
}
if (option.screenshot && !isMobile) {
controls.add(
screenshot({
name: 'screenshot',
position: 'right',
index: 20,
}),
)
}
if (option.setting) {
controls.add(
setting({
name: 'setting',
position: 'right',
index: 30,
}),
)
}
if (option.pip) {
controls.add(
pip({
name: 'pip',
position: 'right',
index: 40,
}),
)
}
if (option.airplay && 'WebKitPlaybackTargetAvailabilityEvent' in window && window.WebKitPlaybackTargetAvailabilityEvent) {
controls.add(
airplay({
name: 'airplay',
position: 'right',
index: 50,
}),
)
}
if (option.fullscreenWeb) {
controls.add(
fullscreenWeb({
name: 'fullscreenWeb',
position: 'right',
index: 60,
}),
)
}
if (option.fullscreen) {
controls.add(
fullscreen({
name: 'fullscreen',
position: 'right',
index: 70,
}),
)
}
for (let index = 0; index < option.controls.length; index++) {
controls.add(option.controls[index]!)
}
}
@@ -1,21 +1,25 @@
import { append, setStyle, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function fullscreen(option) {
export default function fullscreen(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Fullscreen'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $fullscreenOn = append($control, icons.fullscreenOn)
const $fullscreenOff = append($control, icons.fullscreenOff)
const $fullscreenOn = appendElement($control, icons.fullscreenOn)
const $fullscreenOff = appendElement($control, icons.fullscreenOff)
setStyle($fullscreenOff, 'display', 'none')
proxy($control, 'click', () => {
art.fullscreen = !art.fullscreen
})
art.on('fullscreen', (state) => {
on('fullscreen', (state) => {
if (state) {
tooltip($control, i18n.get('Exit Fullscreen'))
setStyle($fullscreenOn, 'display', 'none')
@@ -1,21 +1,25 @@
import { append, setStyle, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function fullscreenWeb(option) {
export default function fullscreenWeb(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Web Fullscreen'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $fullscreenWebOn = append($control, icons.fullscreenWebOn)
const $fullscreenWebOff = append($control, icons.fullscreenWebOff)
const $fullscreenWebOn = appendElement($control, icons.fullscreenWebOn)
const $fullscreenWebOff = appendElement($control, icons.fullscreenWebOff)
setStyle($fullscreenWebOff, 'display', 'none')
proxy($control, 'click', () => {
art.fullscreenWeb = !art.fullscreenWeb
})
art.on('fullscreenWeb', (value) => {
on('fullscreenWeb', (value) => {
if (value) {
tooltip($control, i18n.get('Exit Web Fullscreen'))
setStyle($fullscreenWebOn, 'display', 'none')
-290
View File
@@ -1,290 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import {
addClass,
append,
createElement,
def,
errorHandle,
getComposedPath,
includeFromEvent,
inverseClass,
isMobile,
removeClass,
} from '../utils'
import Component from '../utils/component'
import airplay from './airplay'
import fullscreen from './fullscreen'
import fullscreenWeb from './fullscreenWeb'
import pip from './pip'
import playAndPause from './playAndPause'
import progress from './progress'
import screenshot from './screenshot'
import setting from './setting'
import time from './time'
import volume from './volume'
export default class Control extends Component {
constructor(art) {
super(art)
this.isHover = false
this.name = 'control'
this.timer = Date.now()
const { constructor } = art
const { $player, $bottom } = this.art.template
art.on('mousemove', () => {
if (!isMobile) {
this.show = true
}
})
art.on('click', () => {
if (isMobile) {
this.toggle()
}
else {
this.show = true
}
})
art.on('document:mousemove', (event) => {
this.isHover = includeFromEvent(event, $bottom)
})
art.on('video:timeupdate', () => {
if (
!art.setting.show
&& !this.isHover
&& !art.isInput
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
) {
this.show = false
}
})
art.on('control', (state) => {
if (state) {
removeClass($player, 'art-hide-cursor')
addClass($player, 'art-hover')
this.timer = Date.now()
}
else {
addClass($player, 'art-hide-cursor')
removeClass($player, 'art-hover')
}
})
this.init()
}
init() {
const { option } = this.art
if (!option.isLive) {
this.add(
progress({
name: 'progress',
position: 'top',
index: 10,
}),
)
}
this.add({
name: 'thumbnails',
position: 'top',
index: 20,
})
this.add(
playAndPause({
name: 'playAndPause',
position: 'left',
index: 10,
}),
)
this.add(
volume({
name: 'volume',
position: 'left',
index: 20,
}),
)
if (!option.isLive) {
this.add(
time({
name: 'time',
position: 'left',
index: 30,
}),
)
}
if (option.quality.length) {
wait(getScope(this.art)).then((active) => {
if (!active || getScope(this.art).closed)
return
this.art.quality = option.quality
})
}
if (option.screenshot && !isMobile) {
this.add(
screenshot({
name: 'screenshot',
position: 'right',
index: 20,
}),
)
}
if (option.setting) {
this.add(
setting({
name: 'setting',
position: 'right',
index: 30,
}),
)
}
if (option.pip) {
this.add(
pip({
name: 'pip',
position: 'right',
index: 40,
}),
)
}
if (option.airplay && window.WebKitPlaybackTargetAvailabilityEvent) {
this.add(
airplay({
name: 'airplay',
position: 'right',
index: 50,
}),
)
}
if (option.fullscreenWeb) {
this.add(
fullscreenWeb({
name: 'fullscreenWeb',
position: 'right',
index: 60,
}),
)
}
if (option.fullscreen) {
this.add(
fullscreen({
name: 'fullscreen',
position: 'right',
index: 70,
}),
)
}
for (let index = 0; index < option.controls.length; index++) {
this.add(option.controls[index])
}
}
add(getOption) {
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
const { $progress, $controlsLeft, $controlsRight } = this.art.template
switch (option.position) {
case 'top':
this.$parent = $progress
break
case 'left':
this.$parent = $controlsLeft
break
case 'right':
this.$parent = $controlsRight
break
default:
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
break
}
super.add(option)
}
check(target) {
if (!target) {
return
}
target.$control_value.innerHTML = target.html
for (let index = 0; index < target.$control_option.length; index++) {
const item = target.$control_option[index]
item.default = item === target
if (item.default) {
inverseClass(item.$control_item, 'art-current')
}
}
}
selector(option, $ref, events) {
const { proxy } = this.art.events
addClass($ref, 'art-control-selector')
const $value = createElement('div')
addClass($value, 'art-selector-value')
append($value, option.html)
$ref.textContent = ''
append($ref, $value)
const $list = createElement('div')
addClass($list, 'art-selector-list')
append($ref, $list)
for (let index = 0; index < option.selector.length; index++) {
const item = option.selector[index]
const $item = createElement('div')
addClass($item, 'art-selector-item')
if (item.default)
addClass($item, 'art-current')
$item.dataset.index = index
$item.dataset.value = item.value
$item.innerHTML = item.html
append($list, $item)
def(item, '$control_option', {
get: () => option.selector,
})
def(item, '$control_item', {
get: () => $item,
})
def(item, '$control_value', {
get: () => $value,
})
}
const event = proxy($list, 'click', async (event) => {
const path = getComposedPath(event)
const item = option.selector.find(
item => item.$control_item === path.find($item => item.$control_item === $item),
)
this.check(item)
if (option.onSelect) {
$value.innerHTML = await option.onSelect.call(this.art, item, item.$control_item, event)
}
})
events.push(event)
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { EntryInput, EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { ownEntry } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
import {
addClass,
errorHandle,
includeFromEvent,
isMobile,
removeClass,
} from '../utils'
import Component from '../utils/component'
import { installControls } from './builtins'
import { observeControlLayout } from './layout'
import { controlEvents } from './resources'
import { checkSelector, renderSelector } from './selector'
export default class Control extends Component<ControlHost> {
declare isHover: boolean
declare timer: number
constructor(art: ControlHost) {
super(art)
this.isHover = false
this.name = 'control'
this.timer = Date.now()
const { constructor } = art
const { $player, $bottom } = this.art.template
ownEntry(art, $bottom)
const { on } = controlEvents(art, $bottom)
on('mousemove', () => {
if (!isMobile) {
this.show = true
}
})
on('click', () => {
if (isMobile) {
this.toggle()
}
else {
this.show = true
}
})
on('document:mousemove', (event) => {
this.isHover = includeFromEvent(event, $bottom)
})
on('video:timeupdate', () => {
if (
!art.setting.show
&& !this.isHover
&& !art.isInput
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
) {
this.show = false
}
})
on('control', (state) => {
if (state) {
removeClass($player, 'art-hide-cursor')
addClass($player, 'art-hover')
this.timer = Date.now()
}
else {
addClass($player, 'art-hide-cursor')
removeClass($player, 'art-hover')
}
})
this.init()
if (!getScope(art).closed)
observeControlLayout(art)
}
init(): void {
installControls(this)
}
override add(getOption: EntryInput<ControlHost>): undefined {
if (isClosing(this.art))
return
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
const { $progress, $controlsLeft, $controlsRight } = this.art.template
switch (option.position) {
case 'top':
this.$parent = $progress
break
case 'left':
this.$parent = $controlsLeft
break
case 'right':
this.$parent = $controlsRight
break
default:
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
break
}
super.add(option)
}
check(target?: SelectorItem): void {
checkSelector(target)
}
override selector(option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
renderSelector(this.art, target => this.check(target), option, $ref, events)
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { ControlHost } from './types'
import { entryScope } from '../component/resources'
import { controlEvents } from './resources'
export function observeControlLayout(art: ControlHost): void {
const { $bottom, $controls, $player } = art.template
const scope = entryScope($bottom)
const { on } = controlEvents(art, $bottom)
const update = () => {
if (scope.closed)
return
// Layout height is unaffected by the player's rotation/scale transform.
const height = $controls.offsetHeight
if (height > 0 && $player.style.getPropertyValue('--art-controls-height') !== `${height}px`)
$player.style.setProperty('--art-controls-height', `${height}px`)
}
on('resize', update)
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(update)
observer.observe($controls)
scope.add(() => {
observer.disconnect()
})
}
else if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(update)
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
scope.add(() => {
observer.disconnect()
})
}
update()
}
-21
View File
@@ -1,21 +0,0 @@
import { append, tooltip } from '../utils'
export default function pip(option) {
return art => ({
...option,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
append($control, icons.pip)
proxy($control, 'click', () => {
art.pip = !art.pip
})
art.on('pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
}
+25
View File
@@ -0,0 +1,25 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
export default function pip(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
appendElement($control, icons.pip)
proxy($control, 'click', () => {
art.pip = !art.pip
})
on('pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
}
@@ -1,13 +1,17 @@
import { append, setStyle, silencePromise, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, silencePromise, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function playAndPause(option) {
export default function playAndPause(option: ControlOption): ControlFactory {
return art => ({
...option,
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $play = append($control, icons.play)
const $pause = append($control, icons.pause)
const $play = appendElement($control, icons.play)
const $pause = appendElement($control, icons.pause)
tooltip($play, i18n.get('Play'))
tooltip($pause, i18n.get('Pause'))
@@ -36,11 +40,11 @@ export default function playAndPause(option) {
showPlay()
}
art.on('video:playing', () => {
on('video:playing', () => {
showPause()
})
art.on('video:pause', () => {
on('video:pause', () => {
showPlay()
})
},
-208
View File
@@ -1,208 +0,0 @@
import { addClass, append, clamp, getRect, includeFromEvent, isMobile, query, removeClass, secondToTime, setStyle } from '../utils'
export function getPosFromEvent(art, event) {
const { $progress } = art.template
const { left } = getRect($progress)
const eventLeft = isMobile ? event.touches[0].clientX : event.clientX
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
const second = (width / $progress.clientWidth) * art.duration
const time = secondToTime(second)
const percentage = clamp(width / $progress.clientWidth, 0, 1)
return { second, time, width, percentage }
}
export function setCurrentTime(art, event) {
if (art.isRotate) {
const percentage = (event.touches[0].clientY - art.top) / art.height
const second = percentage * art.duration
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
}
export default function progress(options) {
return (art) => {
const { icons, option, proxy } = art
const { $player, $progress } = art.template
return {
...options,
html: `
<div class="art-control-progress-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
<div class="art-progress-highlight"></div>
<div class="art-progress-indicator"></div>
<div class="art-progress-tip">00:00</div>
</div>
`,
mounted: ($control) => {
let tipTimer = null
let isDragging = false
const $hover = query('.art-progress-hover', $control)
const $loaded = query('.art-progress-loaded', $control)
const $played = query('.art-progress-played', $control)
const $highlight = query('.art-progress-highlight', $control)
const $indicator = query('.art-progress-indicator', $control)
const $tip = query('.art-progress-tip', $control)
if (icons.indicator) {
append($indicator, icons.indicator)
}
else {
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
}
function showHighlight(event) {
const { width } = getPosFromEvent(art, event)
const { text } = event.target.dataset
$tip.textContent = text
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function showTime(event, touch) {
const { width, time } = touch || getPosFromEvent(art, event)
$tip.textContent = time || '00:00'
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function updateHighlight() {
$highlight.textContent = ''
for (let index = 0; index < option.highlight.length; index++) {
const item = option.highlight[index]
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
const html = `<span data-text="${item.text}" data-time="${item.time}" style="left: ${left}%"></span>`
append($highlight, html)
}
}
function setBar(type, percentage, event) {
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'loaded') {
setStyle($loaded, 'width', `${percentage * 100}%`)
}
if (type === 'hover') {
setStyle($hover, 'width', `${percentage * 100}%`)
if (includeFromEvent(event, $highlight)) {
showHighlight(event)
}
else {
showTime(event)
}
if (percentage === 0) {
removeClass($player, 'art-progress-hover')
}
else {
addClass($player, 'art-progress-hover')
}
}
if (type === 'played') {
setStyle($played, 'width', `${percentage * 100}%`)
setStyle($indicator, 'left', `${percentage * 100}%`)
}
if (isMobileDragging) {
addClass($player, 'art-progress-hover')
const width = $control.clientWidth * percentage
const time = secondToTime(percentage * art.duration)
showTime(event, { width, time })
clearTimeout(tipTimer)
tipTimer = setTimeout(() => {
removeClass($player, 'art-progress-hover')
}, 500)
}
}
art.on('setBar', setBar)
art.on('video:loadedmetadata', updateHighlight)
if (art.constructor.USE_RAF) {
art.on('raf', () => {
art.emit('setBar', 'played', art.played)
art.emit('setBar', 'loaded', art.loaded)
})
}
else {
art.on('video:timeupdate', () => {
art.emit('setBar', 'played', art.played)
})
art.on('video:progress', () => {
art.emit('setBar', 'loaded', art.loaded)
})
art.on('video:ended', () => {
art.emit('setBar', 'played', 1)
})
}
art.emit('setBar', 'loaded', art.loaded || 0)
if (!isMobile) {
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
}
})
proxy($progress, 'mousemove', (event) => {
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
art.emit('setBar', 'hover', 0, event)
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
})
art.on('document:mousemove', (event) => {
if (isDragging) {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
})
art.on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
})
}
},
}
}
}
@@ -0,0 +1,25 @@
import type { ControlFactory, ControlOption } from './types'
import { installProgressInteractions } from './progress/interactions'
import { mountProgressView } from './progress/view'
export { getPosFromEvent, setCurrentTime } from './progress/position'
export default function progress(options: ControlOption): ControlFactory {
return art => ({
...options,
html: `
<div class="art-control-progress-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
<div class="art-progress-highlight"></div>
<div class="art-progress-indicator"></div>
<div class="art-progress-tip">00:00</div>
</div>
`,
mounted: ($control) => {
mountProgressView(art, $control)
installProgressInteractions(art, $control)
},
})
}
@@ -0,0 +1,45 @@
import type { ControlHost } from '../types'
import { isMobile } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent, setCurrentTime } from './position'
export function installProgressInteractions(art: ControlHost, $control: HTMLDivElement): void {
const { $progress } = art.template
const $indicator = $control.querySelector('.art-progress-indicator')
const { on, proxy } = controlEvents(art, $control)
if (!isMobile) {
let isDragging = false
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
}
})
proxy($progress, 'mousemove', (event) => {
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
art.emit('setBar', 'hover', 0, event)
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
})
on('document:mousemove', (event) => {
if (isDragging) {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
})
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
})
}
}
@@ -0,0 +1,30 @@
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 function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
const { $progress } = art.template
const { left } = getRect($progress)
const eventLeft = isMobile ? (event as TouchEvent).touches[0]!.clientX : (event as MouseEvent).clientX
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
const second = (width / $progress.clientWidth) * art.duration
const time = secondToTime(second)
const percentage = clamp(width / $progress.clientWidth, 0, 1)
return { second, time, width, percentage }
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
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
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
}
@@ -0,0 +1,141 @@
import type { ControlHost } from '../types'
import type { ProgressEvent } from './position'
import { queryElement } from '../../component/dom'
import { entryScope } from '../../component/resources'
import { timeout } from '../../lifecycle/resources'
import { addClass, append, clamp, includeFromEvent, isMobile, removeClass, secondToTime, setStyle } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent } from './position'
export function mountProgressView(art: ControlHost, $control: HTMLDivElement): void {
const { icons, option } = art
const { $player } = art.template
const scope = entryScope($control)
const { on } = controlEvents(art, $control)
let cancelTip = () => {}
const $hover = queryElement('.art-progress-hover', $control)
const $loaded = queryElement('.art-progress-loaded', $control)
const $played = queryElement('.art-progress-played', $control)
const $highlight = queryElement('.art-progress-highlight', $control)
const $indicator = queryElement('.art-progress-indicator', $control)
const $tip = queryElement('.art-progress-tip', $control)
if (icons.indicator) {
append($indicator, icons.indicator)
}
else {
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
}
function showHighlight(event: ProgressEvent) {
const { width } = getPosFromEvent(art, event)
const text = event.target instanceof HTMLElement ? event.target.dataset.text : undefined
$tip.textContent = text ?? ''
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function showTime(event: ProgressEvent, touch?: { width: number, time: string }) {
const { width, time } = touch || getPosFromEvent(art, event)
$tip.textContent = time || '00:00'
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function updateHighlight() {
$highlight.textContent = ''
for (let index = 0; index < option.highlight.length; index++) {
const item = option.highlight[index]!
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
const marker = document.createElement('span')
marker.dataset.text = item.text
marker.dataset.time = String(item.time)
marker.style.left = `${left}%`
append($highlight, marker)
}
}
function setBar(type: string, percentage: number, event?: ProgressEvent) {
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'loaded') {
setStyle($loaded, 'width', `${percentage * 100}%`)
}
if (type === 'hover') {
setStyle($hover, 'width', `${percentage * 100}%`)
if (includeFromEvent(event!, $highlight)) {
showHighlight(event!)
}
else {
showTime(event!)
}
if (percentage === 0) {
removeClass($player, 'art-progress-hover')
}
else {
addClass($player, 'art-progress-hover')
}
}
if (type === 'played') {
setStyle($played, 'width', `${percentage * 100}%`)
setStyle($indicator, 'left', `${percentage * 100}%`)
}
if (isMobileDragging) {
addClass($player, 'art-progress-hover')
const width = $control.clientWidth * percentage
const time = secondToTime(percentage * art.duration)
showTime(event!, { width, time })
cancelTip()
cancelTip = timeout(scope, () => {
removeClass($player, 'art-progress-hover')
}, 500)
}
}
on('setBar', setBar)
on('video:loadedmetadata', updateHighlight)
if (art.constructor.USE_RAF) {
on('raf', () => {
art.emit('setBar', 'played', art.played)
art.emit('setBar', 'loaded', art.loaded)
})
}
else {
on('video:timeupdate', () => {
art.emit('setBar', 'played', art.played)
})
on('video:progress', () => {
art.emit('setBar', 'loaded', art.loaded)
})
on('video:ended', () => {
art.emit('setBar', 'played', 1)
})
}
art.emit('setBar', 'loaded', art.loaded || 0)
}
@@ -0,0 +1,13 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type { UIEvents } from './types'
import { proxyEntry, subscribeEntry } from '../component/resources'
export function controlEvents(art: ComponentHost & SubscriptionHost<UIEvents>, element: HTMLElement) {
return {
on: <Name extends keyof UIEvents>(name: Name, callback: (...args: [...UIEvents[Name]]) => unknown): void => {
subscribeEntry<UIEvents, Name>(art, element, name, callback)
},
proxy: <Name extends keyof HTMLElementEventMap>(target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): (() => void) => proxyEntry(art, element, target, name, callback),
}
}
@@ -1,16 +0,0 @@
import { append } from '../utils'
export default function screenshot(option) {
return art => ({
...option,
tooltip: art.i18n.get('Screenshot'),
mounted: ($control) => {
const { proxy, icons } = art
append($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
})
},
})
}
@@ -0,0 +1,19 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { controlEvents } from './resources'
export default function screenshot(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Screenshot'),
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
appendElement($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
})
},
})
}
@@ -0,0 +1,96 @@
import type { EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { trackSelection } from '../component/selection'
import { addClass, append, def, errorHandle, getComposedPath, inverseClass } from '../utils'
interface Binding {
option: EntryOption<ControlHost>
item: HTMLDivElement
value: HTMLDivElement
owner: HTMLDivElement
}
const bindings = new WeakMap<SelectorItem, Binding>()
function bind(item: SelectorItem, binding: Binding): void {
const previous = bindings.get(item)
errorHandle(!previous || entryScope(previous.owner).closed, 'Cannot share selector items between active controls')
if (!previous) {
def(item, '$control_option', { get: () => bindings.get(item)!.option.selector })
def(item, '$control_item', { get: () => bindings.get(item)!.item })
def(item, '$control_value', { get: () => bindings.get(item)!.value })
}
bindings.set(item, binding)
}
// WebIDL keeps legacy numeric/Element coercion and null-to-empty innerHTML behavior.
function setHTML(element: HTMLElement, value: unknown): void {
element.innerHTML = value as string
}
export function checkSelector(target?: SelectorItem): void {
if (!target)
return
setHTML(target.$control_value!, target.html)
for (let index = 0; index < target.$control_option!.length; index++) {
const item = target.$control_option![index]!
item.default = item === target
if (item.default)
inverseClass(item.$control_item, 'art-current')
}
}
export function renderSelector(art: ControlHost, check: (target?: SelectorItem) => void, option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
const { proxy } = art.events
const scope = entryScope($ref)
const selector = option.selector!
addClass($ref, 'art-control-selector')
const $value = document.createElement('div')
addClass($value, 'art-selector-value')
append($value, option.html)
$ref.textContent = ''
append($ref, $value)
const $list = appendElement($ref, '<div class="art-selector-list"></div>')
for (let index = 0; index < selector.length; index++) {
const item = selector[index]!
const $item = document.createElement('div')
addClass($item, 'art-selector-item')
if (item.default)
addClass($item, 'art-current')
$item.dataset.index = String(index)
$item.dataset.value = String(item.value)
setHTML($item, item.html)
append($list, $item)
bind(item, { option, item: $item, value: $value, owner: $ref })
}
let generation = 0
const event = proxy($list, 'click', async (event) => {
if (scope.closed)
return
const path = getComposedPath(event)
const item = option.selector!.find(item => path.includes(item.$control_item))
if (!item)
return
const current = ++generation
const active = () => !scope.closed && current === generation
const release = scope.add(trackSelection(event, active))
try {
check(item)
if (scope.closed)
return
if (option.onSelect) {
const value = await option.onSelect.call(art, item, item.$control_item!, event)
if (active())
setHTML($value, value)
}
}
catch (error) {
console.warn('ArtPlayer selector failed:', error)
}
finally {
release()
}
})
events.push(event)
}
-22
View File
@@ -1,22 +0,0 @@
import { append, tooltip } from '../utils'
export default function setting(option) {
return art => ({
...option,
tooltip: art.i18n.get('Show Setting'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
append($control, icons.setting)
proxy($control, 'click', () => {
art.setting.toggle()
art.setting.resize()
})
art.on('setting', (value) => {
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
export default function setting(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Show Setting'),
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
appendElement($control, icons.setting)
proxy($control, 'click', () => {
art.setting.toggle()
art.setting.resize()
})
on('setting', (value) => {
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
})
}
@@ -1,6 +1,8 @@
import type { ControlFactory, ControlOption } from './types'
import { isMobile, secondToTime } from '../utils'
import { controlEvents } from './resources'
export default function time(option) {
export default function time(option: ControlOption): ControlFactory {
return art => ({
...option,
style: isMobile
@@ -13,6 +15,7 @@ export default function time(option) {
padding: '0 10px',
},
mounted: ($control) => {
const { on } = controlEvents(art, $control)
function getTime() {
const newTime = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
if (newTime !== $control.textContent) {
@@ -22,9 +25,9 @@ export default function time(option) {
getTime()
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress']
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress'] as const
for (let index = 0; index < events.length; index++) {
art.on(events[index], getTime)
on(events[index]!, getTime)
}
},
})
+82
View File
@@ -0,0 +1,82 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
export interface UIEvents {
'mousemove': [MouseEvent]
'click': [MouseEvent]
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
'video:timeupdate': [Event]
'video:loadedmetadata': [Event]
'video:progress': [Event]
'video:ended': [Event]
'video:playing': [Event]
'video:pause': [Event]
'video:volumechange': [Event]
'video:ratechange': [Event]
'setBar': [type: string, percentage: number, event?: MouseEvent | TouchEvent]
'raf': []
'control': [boolean]
'fullscreen': [boolean]
'fullscreenWeb': [boolean]
'pip': [boolean]
'setting': [boolean]
'aspectRatio': [string]
'flip': [string]
'blur': []
'resize': []
}
export interface QualityItem { html: string, 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> {
template: {
$player: HTMLDivElement
$bottom: HTMLDivElement
$controls: HTMLDivElement
$progress: HTMLDivElement
$controlsLeft: HTMLDivElement
$controlsRight: HTMLDivElement
}
option: {
isLive: boolean
quality: QualityItem[]
screenshot: boolean
setting: boolean
pip: boolean
airplay: boolean
fullscreenWeb: boolean
fullscreen: boolean
controls: EntryInput<ControlHost>[]
highlight: { time: number, text: string }[]
}
icons: Record<IconName, HTMLElement> & { indicator?: HTMLElement }
i18n: { get: (key: string) => string }
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean }
setting: { show: boolean, toggle: () => void, resize: () => void }
isInput: boolean
isRotate: boolean
playing: boolean
muted: boolean
volume: number
currentTime: number
duration: number
played: number
loaded: number
top: number
height: number
fullscreen: boolean
fullscreenWeb: boolean
pip: boolean
set seek(second: number)
set quality(value: QualityItem[])
play: () => unknown
pause: () => unknown
screenshot: () => unknown
airplay: () => unknown
}
export type ControlOption = EntryOption<ControlHost>
export type ControlFactory = (art: ControlHost) => ControlOption
@@ -1,22 +1,26 @@
import { append, getRect, isMobile, setStyle } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { getRect, isMobile, setStyle } from '../utils'
import { controlEvents } from './resources'
export default function volume(option) {
export default function volume(option: ControlOption): ControlFactory {
return art => ({
...option,
mounted: ($control) => {
const { proxy, icons } = art
const { on, proxy } = controlEvents(art, $control)
const { icons } = art
const $volume = append($control, icons.volume)
const $close = append($control, icons.volumeClose)
const $panel = append($control, '<div class="art-volume-panel"></div>')
const $inner = append($panel, '<div class="art-volume-inner"></div>')
const $value = append($inner, `<div class="art-volume-val"></div>`)
const $slider = append($inner, `<div class="art-volume-slider"></div>`)
const $handle = append($slider, `<div class="art-volume-handle"></div>`)
const $loaded = append($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = append($slider, `<div class="art-volume-indicator"></div>`)
const $volume = appendElement($control, icons.volume)
const $close = appendElement($control, icons.volumeClose)
const $panel = appendElement($control, '<div class="art-volume-panel"></div>')
const $inner = appendElement($panel, '<div class="art-volume-inner"></div>')
const $value = appendElement($inner, `<div class="art-volume-val"></div>`)
const $slider = appendElement($inner, `<div class="art-volume-slider"></div>`)
const $handle = appendElement($slider, `<div class="art-volume-handle"></div>`)
const $loaded = appendElement($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = appendElement($slider, `<div class="art-volume-indicator"></div>`)
function getVolumeFromEvent(event) {
function getVolumeFromEvent(event: MouseEvent) {
const { top, height } = getRect($slider)
return 1 - (event.clientY - top) / height
}
@@ -27,7 +31,7 @@ export default function volume(option) {
setStyle($close, 'display', 'flex')
setStyle($indicator, 'top', '100%')
setStyle($loaded, 'top', '100%')
$value.textContent = 0
$value.textContent = '0'
}
else {
const percentage = art.volume * 100
@@ -35,12 +39,12 @@ export default function volume(option) {
setStyle($close, 'display', 'none')
setStyle($indicator, 'top', `${100 - percentage}%`)
setStyle($loaded, 'top', `${100 - percentage}%`)
$value.textContent = Math.floor(percentage)
$value.textContent = String(Math.floor(percentage))
}
}
update()
art.on('video:volumechange', update)
on('video:volumechange', update)
proxy($volume, 'click', () => {
art.muted = true
@@ -61,14 +65,14 @@ export default function volume(option) {
art.volume = getVolumeFromEvent(event)
})
art.on('document:mousemove', (event) => {
on('document:mousemove', (event) => {
if (isDragging) {
art.muted = false
art.volume = getVolumeFromEvent(event)
}
})
art.on('document:mouseup', () => {
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}