@oplayer/core
Version:
Oh! Another HTML5 video player.
586 lines (499 loc) • 14.7 kB
text/typescript
import { EVENTS, PLAYER_EVENTS, VIDEO_EVENTS } from './constants'
import EventEmitter from './event'
import I18n from './i18n'
import $ from './utils/dom'
import { isQQBrowser } from './utils/platform'
import type {
Destroyable,
PlayerEvent,
PlayerEventName,
PlayerListener,
PlayerOptions,
PlayerPlugin,
Source
} from './types'
const defaultOptions = {
autoplay: false,
muted: false,
loop: false,
volume: 1,
preload: 'metadata',
playbackRate: 1,
playsinline: true,
lang: 'auto',
source: {},
videoAttr: {},
isLive: false,
autopause: true,
isNativeUI: () => isQQBrowser
}
export class Player<Context extends Record<string, any> = Record<string, any>> {
static players: Player[] = []
container: HTMLElement
options: Required<PlayerOptions>
locales: I18n
eventEmitter: EventEmitter
plugins: PlayerPlugin[] = []
context: Context = {} as Context
// hls|dash|etc. instance
loader?: Destroyable
$root!: HTMLDivElement
$video!: HTMLVideoElement
listeners: Record<(typeof EVENTS)[number] | 'fullscreenchange' | 'fullscreenerror', Function> =
Object.create(null)
hasError: boolean = false
isSourceChanging: boolean = false
constructor(el: HTMLElement | string, options?: PlayerOptions | string) {
this.container = typeof el == 'string' ? document.querySelector(el)! : el
if (!this.container) throw new Error((typeof el == 'string' ? el : 'Element') + 'does not exist')
this.options = Object.assign(
{},
defaultOptions,
typeof options === 'string' ? { source: { src: options } } : options
)
this.locales = new I18n(this.options.lang)
this.eventEmitter = new EventEmitter()
}
static make<Context extends Record<string, any> = Record<string, any>>(
el: HTMLElement | string,
options?: PlayerOptions | string
) {
return new Player<Context>(el, options)
}
use(plugins: PlayerPlugin[]) {
plugins.forEach((plugin) => {
this.plugins.push(plugin)
})
return this
}
create() {
this.render()
this.initEvent()
this.plugins.forEach((plugin) => this.applyPlugin(plugin, true))
if (this.options.source.src) this.load(this.options.source)
Player.players.push(this)
return this
}
initEvent() {
const errorHandler = (payload: ErrorEvent) => {
if (this.$video.error) {
this.hasError = true
this.eventEmitter.emit('error', payload)
}
}
this.listeners['error'] = errorHandler
this.$video.addEventListener('error', (e) => this.listeners['error'](e))
const eventHandler = (eventName: string, payload: Event) => {
this.eventEmitter.emit(eventName, payload)
}
;(
[
[
this.$video,
['fullscreenchange', 'webkitbeginfullscreen', 'webkitendfullscreen'],
['fullscreenerror', 'webkitfullscreenerror', 'mozfullscreenerror']
],
[
this.$root,
['fullscreenchange', 'webkitfullscreenchange'],
['fullscreenerror', 'webkitfullscreenerror', 'mozfullscreenerror']
]
] as const
).forEach((it) => {
const [target, ...eventNames] = it
eventNames.forEach((eventName) => {
const polyfillName = eventName[0]
this.listeners[polyfillName] = eventHandler
eventName.forEach((name) => {
target.addEventListener(
name,
(e) => {
this.listeners[polyfillName](polyfillName, e)
},
{ passive: true }
)
})
})
})
;(
[
[this.$video, VIDEO_EVENTS],
[this.$root, PLAYER_EVENTS]
] as const
).forEach(([target, events]) => {
events.forEach((eventName) => {
if (!this.listeners[eventName]) {
this.listeners[eventName] = eventHandler
target.addEventListener(
eventName,
(e) => {
this.listeners?.[eventName](eventName, e)
},
{ passive: true }
)
}
})
})
}
render() {
this.$video = $.create(
`video.${$.css(`
width: 100%;
height: 100%;
display: block;
position: relative;
`)}`,
{
autoplay: this.options.autoplay,
loop: this.options.loop,
playsinline: this.options.playsinline,
'webkit-playsinline': this.options.playsinline,
'x5-playsinline': this.options.playsinline,
preload: this.options.preload,
poster: this.options.source.poster,
...this.options.videoAttr
}
)
// not working `setAttribute`
const { muted, volume, playbackRate } = this.options
if (!!muted) this.mute()
this.$video.volume = volume
// 设置 src 后执行
setTimeout(() => {
// maybe destroyed
if (this.$root) this.setPlaybackRate(playbackRate)
})
this.$root = $.create(
`div.${$.css(`
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #000;
`)}`
)
$.render(this.$video, this.$root)
$.render(this.$root, this.container)
}
async load(source: Source) {
await this.loader?.destroy()
this.loader = undefined
for (const plugin of this.plugins) {
if (plugin.load) {
const returned = await plugin.load(this, source)
if (returned != false && !this.loader) {
this.loader = returned
this.emit('loaderchange', returned)
break
}
}
}
if (!this.loader) {
this.$video.src = source.src
}
return source
}
applyPlugin(plugin: PlayerPlugin, init = false) {
const { name, key } = plugin
if (this.context[plugin.key || plugin.name]) {
throw new Error('duplicate plugin')
}
if (!init) this.plugins.push(plugin)
const returned = plugin.apply(this)
if (returned) {
;(this.context as any)[key || name] = returned
}
}
on(name: PlayerEventName | PlayerListener, listener?: PlayerListener) {
if (typeof name === 'string') {
this.eventEmitter.on(name, listener!)
} else if (Array.isArray(name)) {
this.eventEmitter.onAny(name as string[], listener!)
} else if (typeof name === 'function') {
this.eventEmitter.on('*', name!)
}
return this
}
once(name: PlayerEventName | PlayerListener, listener?: PlayerListener) {
this.eventEmitter.once(name as string, listener!)
}
off(name: PlayerEventName, listener: PlayerListener) {
this.eventEmitter.off(name as string, listener)
}
emit(name: PlayerEventName, payload?: PlayerEvent['payload']) {
this.eventEmitter.emit(name as any, payload)
}
setPoster(poster: string) {
this.$video.poster = poster
}
play() {
if ((!this.$video.src && !this.$video.currentSrc) || this.isSourceChanging) return
if (this.options.autopause) {
for (let i = 0; i < Player.players.length; i++) {
const player = Player.players[i]
if (player != this) player!.pause()
}
}
return this.$video.play()
}
pause() {
return this.$video.pause()
}
togglePlay() {
if (this.isPlaying) {
return this.pause()
} else {
return this.play()
}
}
mute() {
this.$video.muted = true
}
unmute() {
this.$video.muted = false
}
toggleMute() {
if (this.isMuted) {
this.unmute()
} else {
this.mute()
}
}
setVolume(volume: number) {
this.$video.volume = volume > 1 ? 1 : volume < 0 ? 0 : volume
if (this.$video.volume > 0 && this.isMuted) {
this.unmute()
}
}
setPlaybackRate(rate: number) {
this.$video.playbackRate = rate
}
seek(time: number) {
this.$video.currentTime = time
}
setLoop(loop: boolean) {
this.$video.loop = loop
}
async enterFullscreen() {
if (this.isInPip) await this.exitPip()
if (this._requestFullscreen) {
this._requestFullscreen.call(this.$root, { navigationUI: 'hide' })
} else {
;(this.$video as any).webkitEnterFullscreen()
}
}
exitFullscreen() {
return this._exitFullscreen.call(document)
}
get isFullscreenEnabled() {
return (
document.fullscreenEnabled ||
(document as any).webkitFullscreenEnabled ||
(document as any).mozFullScreenEnabled ||
(document as any).msFullscreenEnabled ||
(this.$video as any).webkitEnterFullscreen //ios
)
}
get isFullScreen() {
return Boolean(
(document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement) === this.$root ||
//ios
(this.$video as any).webkitDisplayingFullscreen
)
}
toggleFullScreen() {
if (this.isFullScreen) {
return this.exitFullscreen()
} else {
return this.enterFullscreen()
}
}
get isPipEnabled() {
return document.pictureInPictureEnabled
}
enterPip() {
return this.$video.requestPictureInPicture()
}
exitPip() {
if (this.isInPip) {
return document.exitPictureInPicture()
}
return false
}
get isInPip() {
return document.pictureInPictureElement == this.$video
}
togglePip() {
if (this.isInPip) {
return this.exitPip()
} else {
return this.enterPip()
}
}
changeQuality(source: Omit<Source, 'poster'> | Promise<Omit<Source, 'poster'>>) {
return this._loader(source, {
keepPlaying: true,
keepTime: true,
preEvent: 'videoqualitychange',
event: 'videoqualitychanged',
brokenEvent: 'qualitychangeerror'
})
}
changeSource(source: Source | Promise<Source>, keepPlaying: boolean = true) {
return this._loader(source, {
keepPlaying,
preEvent: 'videosourcechange',
event: 'videosourcechanged',
brokenEvent: 'sourcechangeerror'
})
}
_loader(
sourceLike: Source | Promise<Source>,
options: {
event: string
preEvent: string
brokenEvent: string
keepPlaying: boolean
keepTime?: boolean
}
) {
return new Promise<void>((resolve, reject) => {
if (this.isSourceChanging) return reject(Error('Previous Source is Changing.'))
const { isPlaying, currentTime, volume, playbackRate } = this
this.pause()
this.hasError = false
this.isSourceChanging = true
this.emit(options.preEvent, sourceLike)
const { keepPlaying, keepTime } = options
const isPreloadNone = this.options.preload == 'none'
const canplay = isPreloadNone ? 'loadstart' : 'loadedmetadata'
const shouldPlay = keepPlaying && isPlaying
let finalSource: Source
const errorHandler = (err: any) => {
if (!this.$root) return
this.off(canplay, canplayHandler)
this.emit(options.brokenEvent, { source: finalSource || sourceLike, error: err })
if (options.event == 'videosourcechanged') {
this.isSourceChanging = false
} else {
this.load(this.options.source)
.then(rollback)
.finally(() => {
this.isSourceChanging = false
})
}
reject(err)
}
const rollback = () => {
if (volume != this.volume) this.setVolume(volume)
if (playbackRate != this.playbackRate) this.setPlaybackRate(playbackRate)
if (isPreloadNone && keepTime) this.$video.load()
if (keepTime && !this.options.isLive) this.seek(currentTime)
if (shouldPlay && !this.isPlaying) this.$video.play()
Object.assign(this.options.source, finalSource)
}
const canplayHandler = () => {
if (!this.$root) return
this.off('error', errorHandler)
rollback()
this.isSourceChanging = false
this.emit(options.event, finalSource)
resolve()
}
return (sourceLike instanceof Promise ? sourceLike : Promise.resolve(sourceLike))
.then((source) => {
if (!source.src) throw new Error('Empty Source')
finalSource = source
this.$video.poster = source.poster || ''
this.once('error', errorHandler)
this.once(canplay, canplayHandler)
return source
})
.then((source) => this.load(source))
.catch(errorHandler)
})
}
destroy() {
Player.players.splice(Player.players.indexOf(this), 1)
const { eventEmitter, loader, plugins, container, $root, $video, isPlaying, isFullScreen, isInPip } = this
eventEmitter.emit('destroy')
eventEmitter.offAll()
loader?.destroy()
plugins.forEach((it) => !it.load && it.destroy?.())
if (isPlaying) this.pause()
if (isFullScreen) this.exitFullscreen()
if (isInPip) this.exitPip()
if ($video.src) URL.revokeObjectURL($video.src)
container.removeChild($root)
// prettier-ignore
this.eventEmitter = this.locales = this.options = this.listeners = this.context = this.plugins = this.container = this.$root = this.$video = this.loader = undefined as any
}
get isNativeUI() {
return this.options.isNativeUI()
}
get state() {
return this.$video.readyState
}
get isPlaying() {
return !this.$video.paused
}
get isMuted() {
return this.$video.muted
}
get isEnded() {
return this.$video.ended
}
get isLoop() {
return this.$video.loop
}
get isAutoPlay() {
return this.$video.autoplay
}
get duration() {
return this.$video.duration
}
get buffered() {
return this.$video.buffered
}
get currentTime() {
return this.$video.currentTime
}
get volume() {
return this.$video.volume
}
get playbackRate() {
return this.$video.playbackRate
}
get _requestFullscreen(): Element['requestFullscreen'] {
return (
HTMLElement.prototype.requestFullscreen ||
(HTMLElement.prototype as any).webkitRequestFullscreen ||
(HTMLElement.prototype as any).mozRequestFullScreen ||
(HTMLElement.prototype as any).msRequestFullscreen
)
}
get _exitFullscreen(): Document['exitFullscreen'] {
return (
Document.prototype.exitFullscreen ||
(Document.prototype as any).webkitExitFullscreen ||
(Document.prototype as any).cancelFullScreen ||
(Document.prototype as any).mozCancelFullScreen ||
(Document.prototype as any).msExitFullscreen
)
}
static get version() {
return __VERSION__
}
}
if (globalThis.window) {
console.log(
'%cOPlayer%c v%s\n %c\nOh! Another HTML5 video player.\nhttps://github.com/shiyiya/oplayer\n',
'font-size:32px;',
'font-size:12px;color:#999999;',
Player.version,
'font-size:14px;'
)
}