mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-26 00:31:20 -08:00
refactor(core): CORE-13 type component registries and own control lifetimes
This commit is contained in:
1 parent
9eb7c945ca
commit
697817eceb
68 files changed
+10100
-1925
No files matched your search
@@ -0,0 +1,29 @@
|
||||
import type { ComponentHost, EntryOption } from './types'
|
||||
import { addClass, append, setStyles, tooltip } from '../utils/dom'
|
||||
|
||||
export function renderEntry<Host extends ComponentHost>(element: HTMLDivElement, parent: HTMLElement, kind: string, name: string, id: number, option: EntryOption<Host>): void {
|
||||
addClass(element, `art-${kind}`)
|
||||
addClass(element, `art-${kind}-${name}`)
|
||||
const children = Array.from(parent.children) as HTMLElement[]
|
||||
element.dataset.index = String(option.index || id)
|
||||
const next = children.find(child => Number(child.dataset.index) >= Number(element.dataset.index))
|
||||
if (next)
|
||||
next.insertAdjacentElement('beforebegin', element)
|
||||
else
|
||||
append(parent, element)
|
||||
if (option.html)
|
||||
append(element, option.html)
|
||||
if (option.style)
|
||||
setStyles(element, option.style)
|
||||
if (option.tooltip)
|
||||
tooltip(element, option.tooltip)
|
||||
}
|
||||
|
||||
// Builtin renderers append known element markup or existing icon wrappers.
|
||||
export function appendElement(parent: Element, child: string | Element): HTMLElement {
|
||||
return append(parent, child) as HTMLElement
|
||||
}
|
||||
|
||||
export function queryElement(selector: string, parent: ParentNode): HTMLElement {
|
||||
return parent.querySelector<HTMLElement>(selector)!
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
import type { ComponentHost } from './types'
|
||||
import { getScope } from '../lifecycle/instance'
|
||||
|
||||
const scopes = new WeakMap<HTMLElement, ResourceScope>()
|
||||
|
||||
export function ownEntry(art: ComponentHost, element: HTMLElement): ResourceScope {
|
||||
const scope = getScope(art).child()
|
||||
scopes.set(element, scope)
|
||||
return scope
|
||||
}
|
||||
|
||||
export function entryScope(element: HTMLElement): ResourceScope {
|
||||
const scope = scopes.get(element)
|
||||
if (!scope)
|
||||
throw new Error('ArtPlayer component has not been registered')
|
||||
return scope
|
||||
}
|
||||
|
||||
export function releaseEntry(element: HTMLElement): void {
|
||||
scopes.get(element)?.dispose()
|
||||
}
|
||||
|
||||
export function proxyEntry<Name extends keyof HTMLElementEventMap>(art: ComponentHost, element: HTMLElement, target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): () => void {
|
||||
const scope = entryScope(element)
|
||||
if (scope.closed)
|
||||
return () => {}
|
||||
const cleanup = art.events.proxy(target, name, (event) => {
|
||||
if (!scope.closed)
|
||||
return callback(event as HTMLElementEventMap[Name])
|
||||
})
|
||||
return scope.add(() => {
|
||||
art.events.remove(cleanup)
|
||||
})
|
||||
}
|
||||
|
||||
export interface SubscriptionHost<Events extends { [Name in keyof Events]: readonly unknown[] }> {
|
||||
on: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
|
||||
off: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
|
||||
}
|
||||
|
||||
export function subscribeEntry<Events extends { [Name in keyof Events]: readonly unknown[] }, Name extends keyof Events>(art: SubscriptionHost<Events>, element: HTMLElement, name: Name, callback: (...args: [...Events[Name]]) => unknown): void {
|
||||
const scope = entryScope(element)
|
||||
if (scope.closed)
|
||||
return
|
||||
const guarded = (...args: [...Events[Name]]) => {
|
||||
if (!scope.closed)
|
||||
return callback(...args)
|
||||
}
|
||||
art.on(name, guarded)
|
||||
scope.add(() => {
|
||||
art.off(name, guarded)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Cleanup } from '../lifecycle/scope'
|
||||
|
||||
const selections = new WeakMap<Event, () => boolean>()
|
||||
|
||||
export function trackSelection(event: Event, active: () => boolean): Cleanup {
|
||||
selections.set(event, active)
|
||||
return () => {
|
||||
if (selections.get(event) === active)
|
||||
selections.delete(event)
|
||||
}
|
||||
}
|
||||
|
||||
export function captureSelection(event?: Event): () => boolean {
|
||||
return (event && selections.get(event)) || (() => true)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type Component from '../utils/component'
|
||||
|
||||
export type EventCleanup = () => void
|
||||
|
||||
export interface ComponentHost {
|
||||
template: { $player: HTMLElement }
|
||||
emit: (name: string, ...args: unknown[]) => unknown
|
||||
events: {
|
||||
proxy: (target: EventTarget, name: string, callback: (event: Event) => unknown) => EventCleanup
|
||||
remove: (cleanup: EventCleanup) => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface SelectorItem {
|
||||
html: string | HTMLElement | number
|
||||
value?: string | number
|
||||
default?: boolean
|
||||
readonly $control_option?: SelectorItem[]
|
||||
readonly $control_item?: HTMLDivElement
|
||||
readonly $control_value?: HTMLDivElement
|
||||
}
|
||||
|
||||
export interface EntryOption<Host extends ComponentHost> {
|
||||
html?: string | HTMLElement | number
|
||||
disable?: boolean
|
||||
name?: string
|
||||
index?: number
|
||||
style?: Partial<CSSStyleDeclaration>
|
||||
tooltip?: string | number
|
||||
position?: string
|
||||
selector?: SelectorItem[]
|
||||
click?: (this: Host, component: Component<Host>, event: Event) => unknown
|
||||
mounted?: (this: Host, element: HTMLDivElement) => unknown
|
||||
beforeUnmount?: (this: Host, element: HTMLDivElement) => unknown
|
||||
onSelect?: (this: Host, item: SelectorItem, element: HTMLDivElement, event: Event) => unknown
|
||||
}
|
||||
|
||||
export type EntryInput<Host extends ComponentHost> = EntryOption<Host> | ((art: Host) => EntryOption<Host>)
|
||||
|
||||
export interface Entry<Host extends ComponentHost> {
|
||||
$ref: HTMLDivElement
|
||||
events: EventCleanup[]
|
||||
option: EntryOption<Host>
|
||||
}
|
||||
+8
-5
@@ -1,6 +1,8 @@
|
||||
import { inverseClass, query, queryAll } from '../utils'
|
||||
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||
import { controlEvents } from '../control/resources'
|
||||
import { inverseClass, query } from '../utils'
|
||||
|
||||
export default function aspectRatio(option) {
|
||||
export default function aspectRatio(option: ContextmenuOption): ContextmenuFactory {
|
||||
return (art) => {
|
||||
const {
|
||||
i18n,
|
||||
@@ -15,19 +17,20 @@ export default function aspectRatio(option) {
|
||||
...option,
|
||||
html: `${i18n.get('Aspect Ratio')}: ${html}`,
|
||||
click: (contextmenu, event) => {
|
||||
const { value } = event.target.dataset
|
||||
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
|
||||
if (value) {
|
||||
art.aspectRatio = value
|
||||
contextmenu.show = false
|
||||
}
|
||||
},
|
||||
mounted: ($panel) => {
|
||||
const { on } = controlEvents(art, $panel)
|
||||
const $default = query('[data-value="default"]', $panel)
|
||||
if ($default) {
|
||||
inverseClass($default, 'art-current')
|
||||
}
|
||||
art.on('aspectRatio', (value) => {
|
||||
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
|
||||
on('aspectRatio', (value) => {
|
||||
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
|
||||
if ($current) {
|
||||
inverseClass($current, 'art-current')
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export default function close(option) {
|
||||
return art => ({
|
||||
...option,
|
||||
html: art.i18n.get('Close'),
|
||||
click: (contextmenu) => {
|
||||
contextmenu.show = false
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||
|
||||
export default function close(option: ContextmenuOption): ContextmenuFactory {
|
||||
return art => ({
|
||||
...option,
|
||||
html: art.i18n.get('Close'),
|
||||
click: (contextmenu) => {
|
||||
contextmenu.show = false
|
||||
},
|
||||
})
|
||||
}
|
||||
+8
-5
@@ -1,6 +1,8 @@
|
||||
import { capitalize, inverseClass, query, queryAll } from '../utils'
|
||||
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||
import { controlEvents } from '../control/resources'
|
||||
import { capitalize, inverseClass, query } from '../utils'
|
||||
|
||||
export default function flip(option) {
|
||||
export default function flip(option: ContextmenuOption): ContextmenuFactory {
|
||||
return (art) => {
|
||||
const {
|
||||
i18n,
|
||||
@@ -13,19 +15,20 @@ export default function flip(option) {
|
||||
...option,
|
||||
html: `${i18n.get('Video Flip')}: ${html}`,
|
||||
click: (contextmenu, event) => {
|
||||
const { value } = event.target.dataset
|
||||
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
|
||||
if (value) {
|
||||
art.flip = value.toLowerCase()
|
||||
contextmenu.show = false
|
||||
}
|
||||
},
|
||||
mounted: ($panel) => {
|
||||
const { on } = controlEvents(art, $panel)
|
||||
const $default = query('[data-value="normal"]', $panel)
|
||||
if ($default) {
|
||||
inverseClass($default, 'art-current')
|
||||
}
|
||||
art.on('flip', (value) => {
|
||||
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
|
||||
on('flip', (value) => {
|
||||
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
|
||||
if ($current) {
|
||||
inverseClass($current, 'art-current')
|
||||
}
|
||||
+10
-6
@@ -1,3 +1,6 @@
|
||||
import type { ContextmenuHost } from './types'
|
||||
import { ownEntry } from '../component/resources'
|
||||
import { controlEvents } from '../control/resources'
|
||||
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
|
||||
import Component from '../utils/component'
|
||||
import aspectRatio from './aspectRatio'
|
||||
@@ -7,24 +10,25 @@ import info from './info'
|
||||
import playbackRate from './playbackRate'
|
||||
import version from './version'
|
||||
|
||||
export default class Contextmenu extends Component {
|
||||
constructor(art) {
|
||||
export default class Contextmenu extends Component<ContextmenuHost> {
|
||||
constructor(art: ContextmenuHost) {
|
||||
super(art)
|
||||
|
||||
this.name = 'contextmenu'
|
||||
this.$parent = art.template.$contextmenu
|
||||
ownEntry(art, this.$parent)
|
||||
|
||||
if (!isMobile) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
init(): void {
|
||||
const {
|
||||
option,
|
||||
proxy,
|
||||
template: { $player, $contextmenu },
|
||||
} = this.art
|
||||
const { on, proxy } = controlEvents(this.art, $contextmenu)
|
||||
|
||||
if (option.playbackRate) {
|
||||
this.add(
|
||||
@@ -75,7 +79,7 @@ export default class Contextmenu extends Component {
|
||||
)
|
||||
|
||||
for (let index = 0; index < option.contextmenu.length; index++) {
|
||||
this.add(option.contextmenu[index])
|
||||
this.add(option.contextmenu[index]!)
|
||||
}
|
||||
|
||||
proxy($player, 'contextmenu', (event) => {
|
||||
@@ -112,7 +116,7 @@ export default class Contextmenu extends Component {
|
||||
}
|
||||
})
|
||||
|
||||
this.art.on('blur', () => {
|
||||
on('blur', () => {
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
export default function info(option) {
|
||||
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||
|
||||
export default function info(option: ContextmenuOption): ContextmenuFactory {
|
||||
return art => ({
|
||||
...option,
|
||||
html: art.i18n.get('Video Info'),
|
||||
+8
-5
@@ -1,6 +1,8 @@
|
||||
import { inverseClass, query, queryAll } from '../utils'
|
||||
import type { ContextmenuFactory, ContextmenuOption } from './types'
|
||||
import { controlEvents } from '../control/resources'
|
||||
import { inverseClass, query } from '../utils'
|
||||
|
||||
export default function playbackRate(option) {
|
||||
export default function playbackRate(option: ContextmenuOption): ContextmenuFactory {
|
||||
return (art) => {
|
||||
const {
|
||||
i18n,
|
||||
@@ -15,18 +17,19 @@ export default function playbackRate(option) {
|
||||
...option,
|
||||
html: `${i18n.get('Play Speed')}: ${html}`,
|
||||
click: (contextmenu, event) => {
|
||||
const { value } = event.target.dataset
|
||||
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
|
||||
if (value) {
|
||||
art.playbackRate = Number(value)
|
||||
contextmenu.show = false
|
||||
}
|
||||
},
|
||||
mounted: ($panel) => {
|
||||
const { on } = controlEvents(art, $panel)
|
||||
const $default = query('[data-value="1"]', $panel)
|
||||
if ($default)
|
||||
inverseClass($default, 'art-current')
|
||||
art.on('video:ratechange', () => {
|
||||
const $current = queryAll('span', $panel).find(
|
||||
on('video:ratechange', () => {
|
||||
const $current = Array.from($panel.querySelectorAll('span')).find(
|
||||
item => Number(item.dataset.value) === art.playbackRate,
|
||||
)
|
||||
if ($current) {
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SubscriptionHost } from '../component/resources'
|
||||
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
|
||||
import type { UIEvents } from '../control/types'
|
||||
|
||||
export interface ContextmenuHost extends ComponentHost, SubscriptionHost<UIEvents> {
|
||||
template: { $player: HTMLDivElement, $contextmenu: HTMLDivElement }
|
||||
option: { playbackRate: boolean, aspectRatio: boolean, flip: boolean, contextmenu: EntryInput<ContextmenuHost>[] }
|
||||
constructor: { CONTEXTMENU: boolean, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
|
||||
i18n: { get: (key: string) => string }
|
||||
info: { show: boolean }
|
||||
playbackRate: number
|
||||
aspectRatio: string
|
||||
flip: string
|
||||
}
|
||||
|
||||
export type ContextmenuOption = EntryOption<ContextmenuHost>
|
||||
export type ContextmenuFactory = (art: ContextmenuHost) => ContextmenuOption
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import type { ContextmenuOption } from './types'
|
||||
import { version } from '../../package.json'
|
||||
|
||||
export default function (option) {
|
||||
export default function (option: ContextmenuOption): ContextmenuOption {
|
||||
return {
|
||||
...option,
|
||||
html: `<a href="https://artplayer.org" target="_blank" style="width:100%;">ArtPlayer ${version}</a>`,
|
||||
@@ -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())
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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]!)
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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')
|
||||
+10
-6
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
+11
-7
@@ -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()
|
||||
})
|
||||
},
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
+22
-18
@@ -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
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Component from './utils/component'
|
||||
|
||||
export default class Layer extends Component {
|
||||
constructor(art) {
|
||||
super(art)
|
||||
|
||||
const {
|
||||
option,
|
||||
template: { $layer },
|
||||
} = art
|
||||
|
||||
this.name = 'layer'
|
||||
this.$parent = $layer
|
||||
|
||||
for (let index = 0; index < option.layers.length; index++) {
|
||||
this.add(option.layers[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ComponentHost, EntryInput } from './component/types'
|
||||
import Component from './utils/component'
|
||||
|
||||
export interface LayerHost extends ComponentHost {
|
||||
option: { layers: EntryInput<LayerHost>[] }
|
||||
template: { $player: HTMLElement, $layer: HTMLElement }
|
||||
}
|
||||
|
||||
export default class Layer extends Component<LayerHost> {
|
||||
constructor(art: LayerHost) {
|
||||
super(art)
|
||||
const { option, template: { $layer } } = art
|
||||
this.name = 'layer'
|
||||
this.$parent = $layer
|
||||
for (let index = 0; index < option.layers.length; index++)
|
||||
this.add(option.layers[index]!)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { def } from '../utils'
|
||||
|
||||
export default function qualityMix(art) {
|
||||
def(art, 'quality', {
|
||||
set(quality) {
|
||||
const { controls, notice, i18n } = art
|
||||
const qualityDefault = quality.find(item => item.default) || quality[0]
|
||||
controls.update({
|
||||
name: 'quality',
|
||||
position: 'right',
|
||||
index: 10,
|
||||
style: {
|
||||
marginRight: '10px',
|
||||
},
|
||||
html: qualityDefault?.html || '',
|
||||
selector: quality,
|
||||
async onSelect(item) {
|
||||
await art.switchQuality(item.url)
|
||||
notice.show = `${i18n.get('Switch Video')}: ${item.html}`
|
||||
return item.html
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ComponentHost, EntryOption } from '../component/types'
|
||||
import type { QualityItem } from '../control/types'
|
||||
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) }
|
||||
i18n: { get: (key: string) => string }
|
||||
switchQuality: (url: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export default function qualityMix(art: QualityHost): void {
|
||||
def(art, 'quality', {
|
||||
set(quality: QualityItem[]) {
|
||||
const { controls, notice, i18n } = art
|
||||
const qualityDefault = quality.find(item => item.default) || quality[0]
|
||||
controls.update({
|
||||
name: 'quality',
|
||||
position: 'right',
|
||||
index: 10,
|
||||
style: { marginRight: '10px' },
|
||||
html: qualityDefault?.html || '',
|
||||
selector: quality,
|
||||
async onSelect(item, _element, event) {
|
||||
const active = captureSelection(event)
|
||||
// This callback is installed with the quality array above.
|
||||
await art.switchQuality((item as QualityItem).url)
|
||||
if (!isClosing(art) && active()) {
|
||||
const message = `${i18n.get('Switch Video')}: ${item.html}`
|
||||
if (!isClosing(art) && active())
|
||||
notice.show = message
|
||||
}
|
||||
return item.html
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6,12 +6,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--art-control-height);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
height: auto;
|
||||
min-height: var(--art-control-height);
|
||||
|
||||
.art-controls-left,
|
||||
.art-controls-right {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
min-height: var(--art-control-height);
|
||||
}
|
||||
|
||||
.art-controls-center {
|
||||
@@ -23,11 +28,13 @@
|
||||
|
||||
.art-controls-right {
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.art-control {
|
||||
.art-center();
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
opacity: var(--art-control-opacity);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
padding: 10px;
|
||||
line-height: 1;
|
||||
left: var(--art-padding);
|
||||
bottom: calc(var(--art-control-height) + var(--art-bottom-gap) + 10px);
|
||||
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + 10px);
|
||||
background-color: var(--art-widget-background);
|
||||
|
||||
.art-auto-playback-close {
|
||||
@@ -55,7 +55,12 @@
|
||||
|
||||
.art-controls,
|
||||
.art-progress {
|
||||
transform: translateY(calc(var(--art-control-height) + var(--art-bottom-gap) + var(--art-progress-height) / 4));
|
||||
transform: translateY(
|
||||
calc(
|
||||
var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + var(--art-progress-height) /
|
||||
4
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.art-progress-indicator {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
border-radius: var(--art-border-radius);
|
||||
max-height: var(--art-settings-max-height);
|
||||
right: var(--art-padding);
|
||||
bottom: var(--art-control-height);
|
||||
bottom: var(--art-controls-height, var(--art-control-height));
|
||||
transition: all var(--art-transition-duration) ease;
|
||||
background-color: var(--art-widget-background);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
&.art-control-show {
|
||||
.art-subtitle {
|
||||
bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));
|
||||
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-subtitle-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import validator from 'option-validator'
|
||||
import { ComponentOption } from '../scheme'
|
||||
import { addClass, append, createElement, hasClass, remove, removeClass, setStyles, tooltip } from './dom'
|
||||
import { errorHandle } from './error'
|
||||
|
||||
export default class Component {
|
||||
constructor(art) {
|
||||
this.id = 0
|
||||
this.art = art
|
||||
this.cache = new Map()
|
||||
this.add = this.add.bind(this)
|
||||
this.remove = this.remove.bind(this)
|
||||
this.update = this.update.bind(this)
|
||||
}
|
||||
|
||||
get show() {
|
||||
return hasClass(this.art.template.$player, `art-${this.name}-show`)
|
||||
}
|
||||
|
||||
set show(value) {
|
||||
const { $player } = this.art.template
|
||||
const className = `art-${this.name}-show`
|
||||
if (value) {
|
||||
addClass($player, className)
|
||||
}
|
||||
else {
|
||||
removeClass($player, className)
|
||||
}
|
||||
this.art.emit(this.name, value)
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.show = !this.show
|
||||
}
|
||||
|
||||
add(getOption) {
|
||||
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
|
||||
option.html = option.html || ''
|
||||
validator(option, ComponentOption)
|
||||
if (!this.$parent || !this.name || option.disable)
|
||||
return
|
||||
const name = option.name || `${this.name}${this.id}`
|
||||
errorHandle(!this.cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
|
||||
|
||||
this.id += 1
|
||||
const $ref = createElement('div')
|
||||
addClass($ref, `art-${this.name}`)
|
||||
addClass($ref, `art-${this.name}-${name}`)
|
||||
|
||||
const childs = Array.from(this.$parent.children)
|
||||
$ref.dataset.index = option.index || this.id
|
||||
const nextChild = childs.find(item => Number(item.dataset.index) >= Number($ref.dataset.index))
|
||||
if (nextChild) {
|
||||
nextChild.insertAdjacentElement('beforebegin', $ref)
|
||||
}
|
||||
else {
|
||||
append(this.$parent, $ref)
|
||||
}
|
||||
|
||||
if (option.html) {
|
||||
append($ref, option.html)
|
||||
}
|
||||
|
||||
if (option.style) {
|
||||
setStyles($ref, option.style)
|
||||
}
|
||||
|
||||
if (option.tooltip) {
|
||||
tooltip($ref, option.tooltip)
|
||||
}
|
||||
|
||||
const events = []
|
||||
if (option.click) {
|
||||
const destroyEvent = this.art.events.proxy($ref, 'click', (event) => {
|
||||
event.preventDefault()
|
||||
option.click.call(this.art, this, event)
|
||||
})
|
||||
events.push(destroyEvent)
|
||||
}
|
||||
|
||||
if (option.selector && ['left', 'right'].includes(option.position)) {
|
||||
this.selector(option, $ref, events)
|
||||
}
|
||||
|
||||
this[name] = $ref
|
||||
this.cache.set(name, { $ref, events, option })
|
||||
|
||||
if (option.mounted) {
|
||||
option.mounted.call(this.art, $ref)
|
||||
}
|
||||
|
||||
return $ref
|
||||
}
|
||||
|
||||
remove(name) {
|
||||
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
|
||||
const item = this.cache.get(name)
|
||||
|
||||
if (item.option.beforeUnmount) {
|
||||
item.option.beforeUnmount.call(this.art, item.$ref)
|
||||
}
|
||||
|
||||
for (const event of item.events) {
|
||||
this.art.events.remove(event)
|
||||
}
|
||||
|
||||
this.cache.delete(name)
|
||||
delete this[name]
|
||||
remove(item.$ref)
|
||||
}
|
||||
|
||||
update(option) {
|
||||
if (this.cache.has(option.name)) {
|
||||
const item = this.cache.get(option.name)
|
||||
option = Object.assign(item.option, option)
|
||||
this.remove(option.name)
|
||||
}
|
||||
return this.add(option)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ComponentHost, Entry, EntryInput, EntryOption, EventCleanup } from '../component/types'
|
||||
import validator from 'option-validator'
|
||||
import { renderEntry } from '../component/dom'
|
||||
import { ownEntry, releaseEntry } from '../component/resources'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { ComponentOption } from '../scheme'
|
||||
import { addClass, hasClass, removeClass } from './dom'
|
||||
import { errorHandle } from './error'
|
||||
|
||||
const removing = new WeakSet<object>()
|
||||
|
||||
export default class Component<Host extends ComponentHost = ComponentHost> {
|
||||
declare id: number
|
||||
declare art: Host
|
||||
declare cache: Map<string, Entry<Host>>
|
||||
declare name?: string
|
||||
declare $parent?: HTMLElement
|
||||
selector?(option: EntryOption<Host>, element: HTMLDivElement, events: EventCleanup[]): void
|
||||
|
||||
constructor(art: Host) {
|
||||
this.id = 0
|
||||
this.art = art
|
||||
this.cache = new Map()
|
||||
this.add = this.add.bind(this)
|
||||
this.remove = this.remove.bind(this)
|
||||
this.update = this.update.bind(this)
|
||||
}
|
||||
|
||||
get show(): boolean {
|
||||
return hasClass(this.art.template.$player, `art-${this.name}-show`)
|
||||
}
|
||||
|
||||
set show(value: boolean) {
|
||||
const { $player } = this.art.template
|
||||
const className = `art-${this.name}-show`
|
||||
if (value)
|
||||
addClass($player, className)
|
||||
else
|
||||
removeClass($player, className)
|
||||
this.art.emit(this.name!, value)
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.show = !this.show
|
||||
}
|
||||
|
||||
add(getOption: EntryInput<Host>): HTMLDivElement | undefined {
|
||||
if (isClosing(this.art))
|
||||
return
|
||||
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
|
||||
if (isClosing(this.art))
|
||||
return
|
||||
option.html = option.html || ''
|
||||
validator(option, ComponentOption)
|
||||
if (!this.$parent || !this.name || option.disable)
|
||||
return
|
||||
const name = option.name || `${this.name}${this.id}`
|
||||
const cache = this.cache
|
||||
errorHandle(!cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
|
||||
this.id += 1
|
||||
const $ref = document.createElement('div')
|
||||
const scope = ownEntry(this.art, $ref)
|
||||
const events: EventCleanup[] = []
|
||||
const previous = Object.getOwnPropertyDescriptor(this, name)
|
||||
let aliased = false
|
||||
scope.add(() => {
|
||||
for (const event of events)
|
||||
this.art.events.remove(event)
|
||||
})
|
||||
try {
|
||||
if (scope.closed)
|
||||
return
|
||||
renderEntry($ref, this.$parent, this.name, name, this.id, option)
|
||||
if (scope.closed)
|
||||
return
|
||||
if (option.click) {
|
||||
const cleanup = this.art.events.proxy($ref, 'click', (event) => {
|
||||
if (scope.closed)
|
||||
return
|
||||
event.preventDefault()
|
||||
option.click!.call(this.art, this, event)
|
||||
})
|
||||
events.push(cleanup)
|
||||
}
|
||||
if (option.selector && ['left', 'right'].includes(option.position!))
|
||||
this.selector!(option, $ref, events)
|
||||
if (scope.closed)
|
||||
return
|
||||
assignAlias(this, name, $ref)
|
||||
aliased = true
|
||||
cache.set(name, { $ref, events, option })
|
||||
if (option.mounted)
|
||||
option.mounted.call(this.art, $ref)
|
||||
return $ref
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
releaseEntry($ref)
|
||||
}
|
||||
catch (cleanupError) {
|
||||
console.warn('ArtPlayer component cleanup failed:', cleanupError)
|
||||
}
|
||||
if (cache.get(name)?.$ref === $ref)
|
||||
cache.delete(name)
|
||||
if (aliased && (this as unknown as Record<string, unknown>)[name] === $ref) {
|
||||
if (previous)
|
||||
Object.defineProperty(this, name, previous)
|
||||
else
|
||||
delete (this as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
$ref.remove()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
remove(name: string): void {
|
||||
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
|
||||
const item = this.cache.get(name)!
|
||||
if (removing.has(item))
|
||||
return
|
||||
removing.add(item)
|
||||
try {
|
||||
if (item.option.beforeUnmount)
|
||||
item.option.beforeUnmount.call(this.art, item.$ref)
|
||||
try {
|
||||
releaseEntry(item.$ref)
|
||||
}
|
||||
finally {
|
||||
if (this.cache.get(name) === item) {
|
||||
this.cache.delete(name)
|
||||
delete (this as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
item.$ref.remove()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removing.delete(item)
|
||||
}
|
||||
}
|
||||
|
||||
update(option: EntryOption<Host>): ReturnType<this['add']> {
|
||||
if (this.cache.has(option.name!)) {
|
||||
const item = this.cache.get(option.name!)!
|
||||
option = Object.assign(item.option, option)
|
||||
this.remove(option.name!)
|
||||
}
|
||||
// add is polymorphic: Control intentionally returns undefined.
|
||||
return this.add(option) as ReturnType<this['add']>
|
||||
}
|
||||
}
|
||||
|
||||
function assignAlias(owner: object, name: string, value: HTMLElement): void {
|
||||
const aliases = owner as Record<string, unknown>
|
||||
if (name === '__proto__')
|
||||
Object.defineProperty(aliases, name, { value, enumerable: true, configurable: true, writable: true })
|
||||
else
|
||||
aliases[name] = value
|
||||
}
|
||||
Reference in new issue
Block a user