mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-26 08:51:20 -08:00
refactor(core): [CORE-23] complete keyboard controls and focus lifecycle
This commit is contained in:
1 parent
faaa3e8c2d
commit
8ed0a5228c
190 files changed
+53981
-489
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
export default function airplay(option: ControlOption): ControlFactory {
|
||||
@@ -9,6 +11,7 @@ export default function airplay(option: ControlOption): ControlFactory {
|
||||
mounted: ($control) => {
|
||||
const { proxy } = controlEvents(art, $control)
|
||||
const { icons } = art
|
||||
keyboardButton(entryScope($control), $control)
|
||||
appendElement($control, icons.airplay)
|
||||
proxy($control, 'click', () => art.airplay())
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { setStyle, tooltip } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -10,6 +12,7 @@ export default function fullscreen(option: ControlOption): ControlFactory {
|
||||
mounted: ($control) => {
|
||||
const { on, proxy } = controlEvents(art, $control)
|
||||
const { icons, i18n } = art
|
||||
keyboardButton(entryScope($control), $control)
|
||||
|
||||
const $fullscreenOn = appendElement($control, icons.fullscreenOn)
|
||||
const $fullscreenOff = appendElement($control, icons.fullscreenOff)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { setStyle, tooltip } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -10,6 +12,7 @@ export default function fullscreenWeb(option: ControlOption): ControlFactory {
|
||||
mounted: ($control) => {
|
||||
const { on, proxy } = controlEvents(art, $control)
|
||||
const { icons, i18n } = art
|
||||
keyboardButton(entryScope($control), $control)
|
||||
|
||||
const $fullscreenWebOn = appendElement($control, icons.fullscreenWebOn)
|
||||
const $fullscreenWebOff = appendElement($control, icons.fullscreenWebOff)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EntryInput, EntryOption, EventCleanup, SelectorItem } from '../component/types'
|
||||
import type { ControlHost } from './types'
|
||||
import { focusVisibility } from '../accessibility/focus'
|
||||
import { ownEntry } from '../component/resources'
|
||||
import { getScope, isClosing } from '../lifecycle/instance'
|
||||
import {
|
||||
@@ -30,8 +31,22 @@ export default class Control extends Component<ControlHost> {
|
||||
|
||||
const { constructor } = art
|
||||
const { $player, $bottom } = this.art.template
|
||||
ownEntry(art, $bottom)
|
||||
const { on } = controlEvents(art, $bottom)
|
||||
const scope = ownEntry(art, $bottom)
|
||||
const keyboardFocused = focusVisibility(scope, $player, () => {
|
||||
this.show = true
|
||||
})
|
||||
const { on, proxy } = controlEvents(art, $bottom)
|
||||
|
||||
proxy($player, 'focusin', (event) => {
|
||||
art.isFocus = true
|
||||
art.isInput = (event.target as Element | null)?.tagName === 'INPUT'
|
||||
})
|
||||
proxy($player, 'focusout', (event) => {
|
||||
if (!$player.contains(event.relatedTarget as Node | null)) {
|
||||
art.isFocus = false
|
||||
art.isInput = false
|
||||
}
|
||||
})
|
||||
|
||||
on('mousemove', () => {
|
||||
if (!isMobile) {
|
||||
@@ -57,6 +72,7 @@ export default class Control extends Component<ControlHost> {
|
||||
!art.setting.show
|
||||
&& !this.isHover
|
||||
&& !art.isInput
|
||||
&& !keyboardFocused()
|
||||
&& art.playing
|
||||
&& this.show
|
||||
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { tooltip } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -10,6 +12,7 @@ export default function pip(option: ControlOption): ControlFactory {
|
||||
mounted: ($control) => {
|
||||
const { on, proxy } = controlEvents(art, $control)
|
||||
const { icons, i18n } = art
|
||||
keyboardButton(entryScope($control), $control)
|
||||
|
||||
appendElement($control, icons.pip)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { setStyle, silencePromise, tooltip } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -12,6 +14,7 @@ export default function playAndPause(option: ControlOption): ControlFactory {
|
||||
|
||||
const $play = appendElement($control, icons.play)
|
||||
const $pause = appendElement($control, icons.pause)
|
||||
keyboardButton(entryScope($control), $control, () => (art.playing ? $pause : $play).click())
|
||||
tooltip($play, i18n.get('Play'))
|
||||
tooltip($pause, i18n.get('Pause'))
|
||||
|
||||
@@ -24,11 +27,13 @@ export default function playAndPause(option: ControlOption): ControlFactory {
|
||||
})
|
||||
|
||||
function showPlay() {
|
||||
$control.setAttribute('aria-label', i18n.get('Play'))
|
||||
setStyle($play, 'display', 'flex')
|
||||
setStyle($pause, 'display', 'none')
|
||||
}
|
||||
|
||||
function showPause() {
|
||||
$control.setAttribute('aria-label', i18n.get('Pause'))
|
||||
setStyle($play, 'display', 'none')
|
||||
setStyle($pause, 'display', 'flex')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { installProgressInteractions } from './progress/interactions'
|
||||
import { installProgressKeyboard } from './progress/keyboard'
|
||||
import { mountProgressView } from './progress/view'
|
||||
|
||||
export { getPosFromEvent, setCurrentTime } from './progress/position'
|
||||
@@ -20,6 +21,7 @@ export default function progress(options: ControlOption): ControlFactory {
|
||||
mounted: ($control) => {
|
||||
mountProgressView(art, $control)
|
||||
installProgressInteractions(art, $control)
|
||||
installProgressKeyboard(art, $control)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ControlHost } from '../types'
|
||||
import type ResourceScope from '../../lifecycle/scope'
|
||||
import { keyboardSlider } from '../../accessibility/slider'
|
||||
import { entryScope } from '../../component/resources'
|
||||
import { isClosing } from '../../lifecycle/instance'
|
||||
import { captureSource, getSourceScope } from '../../source/operation'
|
||||
import { positionRestoration } from '../../source/restore-position'
|
||||
import { secondToTime } from '../../utils'
|
||||
import { controlEvents } from '../resources'
|
||||
|
||||
export function installProgressKeyboard(art: ControlHost, element: HTMLDivElement): void {
|
||||
const scope = entryScope(element)
|
||||
const { on } = controlEvents(art, element)
|
||||
let revision = 0
|
||||
let pending: ResourceScope | undefined
|
||||
const update = keyboardSlider(scope, element, art.i18n.get('Progress'), () => ({
|
||||
min: 0,
|
||||
max: art.duration,
|
||||
value: art.currentTime,
|
||||
step: art.constructor.SEEK_STEP,
|
||||
text: value => `${secondToTime(value)} / ${secondToTime(Number.isFinite(art.duration) && art.duration > 0 ? art.duration : 0)}`,
|
||||
}), (value) => {
|
||||
const action = ++revision
|
||||
pending?.dispose()
|
||||
const operation = scope.child()
|
||||
pending = operation
|
||||
const releaseSource = getSourceScope(art).add(() => {
|
||||
operation.dispose()
|
||||
})
|
||||
operation.add(() => {
|
||||
releaseSource()
|
||||
})
|
||||
operation.add(() => {
|
||||
if (pending === operation)
|
||||
pending = undefined
|
||||
})
|
||||
const sourceActive = captureSource(art)
|
||||
const active = () => action === revision && !operation.closed && !isClosing(art) && sourceActive()
|
||||
const position = positionRestoration(art, value, active)
|
||||
const settle = () => {
|
||||
if (!active()) {
|
||||
operation.dispose()
|
||||
return
|
||||
}
|
||||
if (position.ready()) {
|
||||
operation.dispose()
|
||||
update()
|
||||
}
|
||||
}
|
||||
for (const name of ['video:seeked', 'video:ended'] as const) {
|
||||
art.on(name, settle)
|
||||
operation.add(() => {
|
||||
art.off(name, settle)
|
||||
})
|
||||
}
|
||||
try {
|
||||
art.emit('setBar', 'played', value / art.duration)
|
||||
if (active()) {
|
||||
position.restore(() => {
|
||||
art.seek = value
|
||||
})
|
||||
}
|
||||
settle()
|
||||
}
|
||||
catch (error) {
|
||||
operation.dispose()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
on('video:loadedmetadata', update)
|
||||
on('video:durationchange', update)
|
||||
on('video:emptied', update)
|
||||
on('video:timeupdate', update)
|
||||
on('video:seeking', update)
|
||||
on('video:seeked', update)
|
||||
on('video:ended', update)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { silencePromise } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -10,6 +12,7 @@ export default function screenshot(option: ControlOption): ControlFactory {
|
||||
mounted: ($control) => {
|
||||
const { proxy } = controlEvents(art, $control)
|
||||
const { icons } = art
|
||||
keyboardButton(entryScope($control), $control)
|
||||
|
||||
appendElement($control, icons.screenshot)
|
||||
proxy($control, 'click', () => {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type ResourceScope from '../lifecycle/scope'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { claimKey, plainKey } from '../accessibility/keyboard'
|
||||
import { listen } from '../lifecycle/resources'
|
||||
|
||||
const interactive = 'button,input,select,textarea,a[href],[tabindex],[contenteditable]'
|
||||
|
||||
export function selectorKeyboard(scope: ResourceScope, owner: HTMLElement, value: HTMLElement, list: HTMLElement, label: string): (restore?: boolean) => void {
|
||||
if (scope.closed)
|
||||
return () => {}
|
||||
const items = Array.from(list.children) as HTMLElement[]
|
||||
const complex = items.some(item => item.querySelector(interactive))
|
||||
list.setAttribute('role', complex ? 'group' : 'listbox')
|
||||
list.setAttribute('aria-label', label)
|
||||
let hovered = false
|
||||
let opened = false
|
||||
let dismissed = false
|
||||
let prefix = ''
|
||||
let typedAt = 0
|
||||
const expanded = () => items.length > 0 && (opened || (hovered && !dismissed))
|
||||
const trigger = () => value.querySelector<HTMLElement>('button,a[href],[role="button"]') || value
|
||||
const refresh = (restore = false) => {
|
||||
if (scope.closed)
|
||||
return
|
||||
const target = trigger()
|
||||
if (value.querySelector(interactive)) {
|
||||
value.removeAttribute('role')
|
||||
value.removeAttribute('tabindex')
|
||||
}
|
||||
else {
|
||||
value.setAttribute('role', 'button')
|
||||
value.tabIndex = 0
|
||||
}
|
||||
if (target !== value) {
|
||||
value.removeAttribute('aria-haspopup')
|
||||
value.removeAttribute('aria-expanded')
|
||||
value.removeAttribute('aria-disabled')
|
||||
}
|
||||
if (complex)
|
||||
target.removeAttribute('aria-haspopup')
|
||||
else
|
||||
target.setAttribute('aria-haspopup', 'listbox')
|
||||
target.setAttribute('aria-expanded', String(expanded()))
|
||||
target.setAttribute('aria-disabled', String(items.length === 0))
|
||||
if (restore && value.isConnected && value.ownerDocument.activeElement === value.ownerDocument.body)
|
||||
target.focus({ preventScroll: true })
|
||||
}
|
||||
const visibility = (state: boolean, hide = false) => {
|
||||
opened = state
|
||||
dismissed = hide
|
||||
owner.classList.toggle('art-selector-open', state)
|
||||
owner.classList.toggle('art-selector-dismissed', hide)
|
||||
refresh()
|
||||
}
|
||||
const focus = (item?: HTMLElement) => {
|
||||
if (!item || scope.closed)
|
||||
return
|
||||
const target = item.querySelector<HTMLElement>(interactive) || item
|
||||
target.focus({ preventScroll: true })
|
||||
if (!scope.closed)
|
||||
item.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
const open = (last = false) => {
|
||||
if (scope.closed || !items.length)
|
||||
return
|
||||
visibility(true)
|
||||
prefix = ''
|
||||
focus(items.find(item => item.classList.contains('art-current')) || items[last ? items.length - 1 : 0])
|
||||
}
|
||||
const close = () => {
|
||||
if (scope.closed)
|
||||
return
|
||||
const restore = list.contains(owner.ownerDocument.activeElement)
|
||||
visibility(false, true)
|
||||
prefix = ''
|
||||
if (restore && value.isConnected)
|
||||
trigger().focus({ preventScroll: true })
|
||||
}
|
||||
keyboardButton(scope, value, () => {
|
||||
value.click()
|
||||
if (owner.ownerDocument.activeElement === value)
|
||||
open()
|
||||
})
|
||||
refresh()
|
||||
for (const item of items) {
|
||||
const selected = item.classList.contains('art-current')
|
||||
if (!complex) {
|
||||
item.setAttribute('role', 'option')
|
||||
item.setAttribute('aria-selected', String(selected))
|
||||
}
|
||||
else {
|
||||
item.setAttribute('aria-current', String(selected))
|
||||
}
|
||||
if (!item.querySelector(interactive)) {
|
||||
item.tabIndex = -1
|
||||
keyboardButton(scope, item, () => {
|
||||
item.click()
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
listen(scope, value, 'keydown', (input) => {
|
||||
const event = input as KeyboardEvent
|
||||
if (!plainKey(event) || event.defaultPrevented || (event.target !== value && event.target !== trigger()))
|
||||
return
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
claimKey(event)
|
||||
open(event.key === 'ArrowUp')
|
||||
}
|
||||
else if (event.key === 'Escape' && expanded()) {
|
||||
claimKey(event)
|
||||
close()
|
||||
}
|
||||
})
|
||||
listen(scope, value, 'click', (event) => {
|
||||
if (event.isTrusted && (event as MouseEvent).detail === 0 && trigger() !== value)
|
||||
open()
|
||||
})
|
||||
listen(scope, list, 'keydown', (input) => {
|
||||
const event = input as KeyboardEvent
|
||||
if (!plainKey(event) || event.defaultPrevented)
|
||||
return
|
||||
if (event.key === 'Escape') {
|
||||
claimKey(event)
|
||||
close()
|
||||
return
|
||||
}
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
if (index < 0)
|
||||
return
|
||||
if (event.key.length !== 1)
|
||||
prefix = ''
|
||||
let next: number | undefined
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
next = Math.min(index + 1, items.length - 1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
next = Math.max(index - 1, 0)
|
||||
break
|
||||
case 'Home':
|
||||
next = 0
|
||||
break
|
||||
case 'End':
|
||||
next = items.length - 1
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
claimKey(event)
|
||||
return
|
||||
default: {
|
||||
if (event.key.length !== 1 || event.key === ' ')
|
||||
return
|
||||
const now = Date.now()
|
||||
prefix = now - typedAt < 500 ? prefix + event.key.toLocaleLowerCase() : event.key.toLocaleLowerCase()
|
||||
typedAt = now
|
||||
const query = [...prefix].every(char => char === prefix[0]) ? prefix[0]! : prefix
|
||||
for (let offset = 1; offset <= items.length; offset++) {
|
||||
const candidate = (index + offset) % items.length
|
||||
if (items[candidate]!.textContent?.trim().toLocaleLowerCase().startsWith(query)) {
|
||||
next = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
claimKey(event)
|
||||
if (next !== undefined)
|
||||
focus(items[next])
|
||||
})
|
||||
listen(scope, list, 'click', (event) => {
|
||||
if (event.isTrusted && (event as MouseEvent).detail === 0)
|
||||
close()
|
||||
})
|
||||
listen(scope, owner, 'mouseenter', () => {
|
||||
hovered = true
|
||||
visibility(opened)
|
||||
})
|
||||
listen(scope, owner, 'mouseleave', () => {
|
||||
hovered = false
|
||||
visibility(list.contains(owner.ownerDocument.activeElement))
|
||||
})
|
||||
listen(scope, owner, 'focusout', (event) => {
|
||||
if (!owner.contains((event as FocusEvent).relatedTarget as Node | null))
|
||||
visibility(false, true)
|
||||
})
|
||||
scope.add(() => {
|
||||
owner.classList.remove('art-selector-open', 'art-selector-dismissed')
|
||||
})
|
||||
return refresh
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { trackSelection } from '../component/selection'
|
||||
import { addClass, append, def, errorHandle, getComposedPath, inverseClass } from '../utils'
|
||||
import { selectorKeyboard } from './selector-keyboard'
|
||||
|
||||
interface Binding {
|
||||
option: EntryOption<ControlHost>
|
||||
item: HTMLDivElement
|
||||
value: HTMLDivElement
|
||||
owner: HTMLDivElement
|
||||
refresh?: (restore?: boolean) => void
|
||||
}
|
||||
const bindings = new WeakMap<SelectorItem, Binding>()
|
||||
|
||||
@@ -32,13 +34,17 @@ function setHTML(element: HTMLElement, value: unknown): void {
|
||||
export function checkSelector(target?: SelectorItem): void {
|
||||
if (!target)
|
||||
return
|
||||
const restore = target.$control_value!.contains(target.$control_value!.ownerDocument.activeElement)
|
||||
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
|
||||
const element = item.$control_item!
|
||||
element.setAttribute(element.getAttribute('role') === 'option' ? 'aria-selected' : 'aria-current', String(item.default))
|
||||
if (item.default)
|
||||
inverseClass(item.$control_item!, 'art-current')
|
||||
}
|
||||
bindings.get(target)?.refresh?.(restore)
|
||||
}
|
||||
|
||||
export function renderSelector(art: ControlHost, check: (target?: SelectorItem) => void, option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
|
||||
@@ -81,8 +87,11 @@ export function renderSelector(art: ControlHost, check: (target?: SelectorItem)
|
||||
return
|
||||
if (option.onSelect) {
|
||||
const value = await option.onSelect.call(art, item, item.$control_item!, event)
|
||||
if (active())
|
||||
if (active()) {
|
||||
const restore = $value.contains($value.ownerDocument.activeElement)
|
||||
setHTML($value, value)
|
||||
bindings.get(item)?.refresh?.(restore)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -93,4 +102,7 @@ export function renderSelector(art: ControlHost, check: (target?: SelectorItem)
|
||||
}
|
||||
})
|
||||
events.push(event)
|
||||
const refresh = selectorKeyboard(scope, $ref, $value, $list, String(option.tooltip || option.name || $value.textContent || art.i18n.get('Open')))
|
||||
for (const item of selector)
|
||||
bindings.get(item)!.refresh = refresh
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { tooltip } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -12,6 +14,8 @@ export default function setting(option: ControlOption): ControlFactory {
|
||||
const { icons, i18n } = art
|
||||
|
||||
appendElement($control, icons.setting)
|
||||
keyboardButton(entryScope($control), $control)
|
||||
$control.setAttribute('aria-expanded', 'false')
|
||||
|
||||
proxy($control, 'click', () => {
|
||||
art.setting.toggle()
|
||||
@@ -19,6 +23,7 @@ export default function setting(option: ControlOption): ControlFactory {
|
||||
})
|
||||
|
||||
on('setting', (value) => {
|
||||
$control.setAttribute('aria-expanded', String(value))
|
||||
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
|
||||
})
|
||||
},
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface UIEvents {
|
||||
'document:mouseup': [MouseEvent]
|
||||
'video:timeupdate': [Event]
|
||||
'video:loadedmetadata': [Event]
|
||||
'video:durationchange': [Event]
|
||||
'video:emptied': [Event]
|
||||
'video:seeking': [Event]
|
||||
'video:seeked': [Event]
|
||||
'video:progress': [Event]
|
||||
'video:ended': [Event]
|
||||
'video:playing': [Event]
|
||||
@@ -22,6 +26,7 @@ export interface UIEvents {
|
||||
'fullscreenWeb': [boolean]
|
||||
'pip': [boolean]
|
||||
'setting': [boolean]
|
||||
'contextmenu': [boolean]
|
||||
'aspectRatio': [string]
|
||||
'flip': [string]
|
||||
'blur': [Event]
|
||||
@@ -34,6 +39,7 @@ type IconName = 'play' | 'pause' | 'volume' | 'volumeClose' | 'fullscreenOn' | '
|
||||
|
||||
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents>, PipProperty {
|
||||
template: {
|
||||
$video?: { readonly currentTime: number, readonly seeking?: boolean }
|
||||
$player: HTMLDivElement
|
||||
$bottom: HTMLDivElement
|
||||
$controls: HTMLDivElement
|
||||
@@ -55,9 +61,10 @@ export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents>,
|
||||
}
|
||||
icons: Record<IconName, HTMLElement> & { indicator?: HTMLElement }
|
||||
i18n: { get: (key: string) => string }
|
||||
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean }
|
||||
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean, SEEK_STEP: number, VOLUME_STEP: number }
|
||||
setting: { show: boolean, toggle: () => void, resize: () => void }
|
||||
isInput: boolean
|
||||
isFocus: boolean
|
||||
isRotate: boolean
|
||||
playing: boolean
|
||||
muted: boolean
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ControlFactory, ControlOption } from './types'
|
||||
import { keyboardButton } from '../accessibility/button'
|
||||
import { keyboardSlider } from '../accessibility/slider'
|
||||
import { appendElement } from '../component/dom'
|
||||
import { entryScope } from '../component/resources'
|
||||
import { isClosing } from '../lifecycle/instance'
|
||||
import { getRect, isMobile, setStyle } from '../utils'
|
||||
import { controlEvents } from './resources'
|
||||
|
||||
@@ -8,10 +12,14 @@ export default function volume(option: ControlOption): ControlFactory {
|
||||
...option,
|
||||
mounted: ($control) => {
|
||||
const { on, proxy } = controlEvents(art, $control)
|
||||
const { icons } = art
|
||||
const { icons, i18n } = art
|
||||
|
||||
const $volume = appendElement($control, icons.volume)
|
||||
const $close = appendElement($control, icons.volumeClose)
|
||||
for (const element of [$volume, $close]) {
|
||||
keyboardButton(entryScope($control), element)
|
||||
element.setAttribute('aria-label', i18n.get('Mute'))
|
||||
}
|
||||
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>`)
|
||||
@@ -19,6 +27,20 @@ export default function volume(option: ControlOption): ControlFactory {
|
||||
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>`)
|
||||
const scope = entryScope($control)
|
||||
let revision = 0
|
||||
const updateSlider = keyboardSlider(scope, $slider, i18n.get('Volume'), () => ({
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: art.muted ? 0 : art.volume * 100,
|
||||
step: art.constructor.VOLUME_STEP * 100,
|
||||
text: value => `${Math.round(value)}%`,
|
||||
}), (value) => {
|
||||
const action = ++revision
|
||||
art.muted = false
|
||||
if (action === revision && !scope.closed && !isClosing(art))
|
||||
art.volume = value / 100
|
||||
}, 'vertical')
|
||||
|
||||
function getVolumeFromEvent(event: MouseEvent) {
|
||||
const { top, height } = getRect($slider)
|
||||
@@ -26,7 +48,13 @@ export default function volume(option: ControlOption): ControlFactory {
|
||||
}
|
||||
|
||||
function update() {
|
||||
if (art.muted || art.volume === 0) {
|
||||
updateSlider()
|
||||
const focused = $control.ownerDocument.activeElement
|
||||
const muted = art.muted || art.volume === 0
|
||||
const visible = muted ? $close : $volume
|
||||
$volume.setAttribute('aria-pressed', String(art.muted))
|
||||
$close.setAttribute('aria-pressed', String(art.muted))
|
||||
if (muted) {
|
||||
setStyle($volume, 'display', 'none')
|
||||
setStyle($close, 'display', 'flex')
|
||||
setStyle($indicator, 'top', '100%')
|
||||
@@ -41,6 +69,8 @@ export default function volume(option: ControlOption): ControlFactory {
|
||||
setStyle($loaded, 'top', `${100 - percentage}%`)
|
||||
$value.textContent = String(Math.floor(percentage))
|
||||
}
|
||||
if ((focused === $volume || focused === $close) && focused !== visible)
|
||||
visible.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
Reference in new issue
Block a user