PeerTube/client/src/assets/player/peertube-player.ts
2024-04-24 15:57:24 +02:00

548 lines
17 KiB
TypeScript

import '@peertube/videojs-contextmenu'
import './shared/upnext/end-card'
import './shared/upnext/upnext-plugin'
import './shared/stats/stats-card'
import './shared/stats/stats-plugin'
import './shared/bezels/bezels-plugin'
import './shared/peertube/peertube-plugin'
import './shared/resolutions/peertube-resolutions-plugin'
import './shared/control-bar/caption-toggle-button'
import './shared/control-bar/storyboard-plugin'
import './shared/control-bar/chapters-plugin'
import './shared/control-bar/time-tooltip'
import './shared/control-bar/next-previous-video-button'
import './shared/control-bar/p2p-info-button'
import './shared/control-bar/peertube-link-button'
import './shared/control-bar/theater-button'
import './shared/control-bar/peertube-live-display'
import './shared/settings/resolution-menu-button'
import './shared/settings/resolution-menu-item'
import './shared/settings/settings-dialog'
import './shared/settings/settings-menu-button'
import './shared/settings/settings-menu-item'
import './shared/settings/settings-panel'
import './shared/settings/settings-panel-child'
import './shared/playlist/playlist-plugin'
import './shared/mobile/peertube-mobile-plugin'
import './shared/mobile/peertube-mobile-buttons'
import './shared/hotkeys/peertube-hotkeys-plugin'
import './shared/metrics/metrics-plugin'
import videojs, { VideoJsPlayer } from 'video.js'
import { logger } from '@root-helpers/logger'
import { PluginsManager } from '@root-helpers/plugins-manager'
import { copyToClipboard } from '@root-helpers/utils'
import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
import { isMobile } from '@root-helpers/web-browser'
import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@peertube/peertube-core-utils'
import { saveAverageBandwidth } from './peertube-player-local-storage'
import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder'
import { TranslationsManager } from './translations-manager'
import { PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types'
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
const CaptionsButton = videojs.getComponent('CaptionsButton') as any
// Change Captions to Subtitles/CC
CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
CaptionsButton.prototype.label_ = ' '
// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged
const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any
if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) {
PlayProgressBar.prototype.options_.children.push('timeTooltip')
}
export { videojs }
export class PeerTubePlayer {
private pluginsManager: PluginsManager
private videojsDecodeErrors = 0
private p2pMediaLoaderModule: any
private player: VideoJsPlayer
private currentLoadOptions: PeerTubePlayerLoadOptions
private moduleLoaded = {
webVideo: false,
p2pMediaLoader: false
}
constructor (private options: PeerTubePlayerContructorOptions) {
this.pluginsManager = options.pluginsManager
}
unload () {
if (!this.player) return
this.disposeDynamicPluginsIfNeeded()
this.player.reset()
}
async load (loadOptions: PeerTubePlayerLoadOptions) {
this.currentLoadOptions = loadOptions
this.setPoster('')
this.disposeDynamicPluginsIfNeeded()
await this.lazyLoadModulesIfNeeded()
await this.buildPlayerIfNeeded()
if (this.currentLoadOptions.mode === 'p2p-media-loader') {
await this.loadP2PMediaLoader()
} else {
this.loadWebVideo()
}
this.loadDynamicPlugins()
if (this.options.controlBar === false) this.player.controlBar.hide()
else this.player.controlBar.show()
this.player.autoplay(this.getAutoPlayValue(this.currentLoadOptions.autoplay))
if (!this.player.autoplay()) {
this.setPoster(loadOptions.poster)
}
this.player.trigger('video-change')
}
getPlayer () {
return this.player
}
destroy () {
if (this.player) this.player.dispose()
}
setPoster (url: string) {
// Use HTML video element to display poster
if (!this.player) {
this.options.playerElement().poster = url
return
}
// Prefer using player poster API
this.player?.poster(url)
this.options.playerElement().poster = ''
}
enable () {
if (!this.player) return
(this.player.el() as HTMLElement).style.pointerEvents = 'auto'
}
disable () {
if (!this.player) return
if (this.player.isFullscreen()) {
this.player.exitFullscreen()
}
// Disable player
this.player.hasStarted(false)
this.player.removeClass('vjs-has-autoplay')
this.player.bigPlayButton.hide();
(this.player.el() as HTMLElement).style.pointerEvents = 'none'
}
private async loadP2PMediaLoader () {
const hlsOptionsBuilder = new HLSOptionsBuilder({
...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]),
...pick(this.currentLoadOptions, [
'videoPassword',
'requiresUserAuth',
'videoFileToken',
'requiresPassword',
'isLive',
'p2pEnabled',
'liveOptions',
'hls'
])
}, this.p2pMediaLoaderModule)
const { hlsjs, p2pMediaLoader } = await hlsOptionsBuilder.getPluginOptions()
this.player.hlsjs(hlsjs)
this.player.p2pMediaLoader(p2pMediaLoader)
}
private loadWebVideo () {
const webVideoOptionsBuilder = new WebVideoOptionsBuilder(pick(this.currentLoadOptions, [
'videoFileToken',
'webVideo',
'hls'
]))
this.player.webVideo(webVideoOptionsBuilder.getPluginOptions())
}
private async buildPlayerIfNeeded () {
if (this.player) return
await TranslationsManager.loadLocaleInVideoJS(this.options.serverUrl, this.options.language, videojs)
const videojsOptions = await this.pluginsManager.runHook(
'filter:internal.player.videojs.options.result',
this.getVideojsOptions()
)
this.player = videojs(this.options.playerElement(), videojsOptions)
this.player.ready(() => {
if (!isNaN(+this.options.playbackRate)) {
this.player.playbackRate(+this.options.playbackRate)
}
let alreadyFallback = false
const handleError = () => {
if (alreadyFallback) return
alreadyFallback = true
if (this.currentLoadOptions.mode === 'p2p-media-loader') {
this.tryToRecoverHLSError(this.player.error())
} else {
this.maybeFallbackToWebVideo()
}
}
this.player.on('video-change', () => alreadyFallback = false)
this.player.on('error', () => handleError())
this.player.on('network-info', (_, data: PlayerNetworkInfo) => {
if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
saveAverageBandwidth(data.bandwidthEstimate)
})
this.player.contextmenuUI(this.getContextMenuOptions())
this.displayNotificationWhenOffline()
})
}
private disposeDynamicPluginsIfNeeded () {
if (!this.player) return
if (this.player.usingPlugin('peertubeMobile')) this.player.peertubeMobile().dispose()
if (this.player.usingPlugin('peerTubeHotkeysPlugin')) this.player.peerTubeHotkeysPlugin().dispose()
if (this.player.usingPlugin('playlist')) this.player.playlist().dispose()
if (this.player.usingPlugin('bezels')) this.player.bezels().dispose()
if (this.player.usingPlugin('upnext')) this.player.upnext().dispose()
if (this.player.usingPlugin('stats')) this.player.stats().dispose()
if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose()
if (this.player.usingPlugin('chapters')) this.player.chapters().dispose()
if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose()
if (this.player.usingPlugin('p2pMediaLoader')) this.player.p2pMediaLoader().dispose()
if (this.player.usingPlugin('hlsjs')) this.player.hlsjs().dispose()
if (this.player.usingPlugin('webVideo')) this.player.webVideo().dispose()
}
private loadDynamicPlugins () {
if (isMobile()) this.player.peertubeMobile()
this.player.bezels()
this.player.stats({
videoUUID: this.currentLoadOptions.videoUUID,
videoIsLive: this.currentLoadOptions.isLive,
mode: this.currentLoadOptions.mode,
p2pEnabled: this.currentLoadOptions.p2pEnabled
})
if (this.options.enableHotkeys === true) {
this.player.peerTubeHotkeysPlugin({ isLive: this.currentLoadOptions.isLive })
}
if (this.currentLoadOptions.playlist) {
this.player.playlist(this.currentLoadOptions.playlist)
}
if (this.currentLoadOptions.upnext) {
this.player.upnext({
timeout: this.currentLoadOptions.upnext.timeout,
getTitle: () => this.currentLoadOptions.nextVideo.getVideoTitle(),
next: () => this.currentLoadOptions.nextVideo.handler(),
isDisplayed: () => this.currentLoadOptions.nextVideo.enabled && this.currentLoadOptions.upnext.isEnabled(),
isSuspended: () => this.currentLoadOptions.upnext.isSuspended(this.player)
})
}
if (this.currentLoadOptions.storyboard) {
this.player.storyboard(this.currentLoadOptions.storyboard)
}
if (this.currentLoadOptions.videoChapters) {
this.player.chapters({ chapters: this.currentLoadOptions.videoChapters })
}
if (this.currentLoadOptions.dock) {
this.player.peertubeDock(this.currentLoadOptions.dock)
}
}
private async lazyLoadModulesIfNeeded () {
if (this.currentLoadOptions.mode === 'web-video' && this.moduleLoaded.webVideo !== true) {
await import('./shared/web-video/web-video-plugin')
}
if (this.currentLoadOptions.mode === 'p2p-media-loader' && this.moduleLoaded.p2pMediaLoader !== true) {
const [ p2pMediaLoaderModule ] = await Promise.all([
import('@peertube/p2p-media-loader-hlsjs'),
import('./shared/p2p-media-loader/hls-plugin'),
import('./shared/p2p-media-loader/p2p-media-loader-plugin')
])
this.p2pMediaLoaderModule = p2pMediaLoaderModule
}
}
private async tryToRecoverHLSError (err: any) {
if (err.code === MediaError.MEDIA_ERR_DECODE) {
// Display a notification to user
if (this.videojsDecodeErrors === 0) {
this.options.errorNotifier(this.player.localize('The video failed to play, will try to fast forward.'))
}
if (this.videojsDecodeErrors === 20) {
this.maybeFallbackToWebVideo()
return
}
logger.info('Fast forwarding HLS to recover from an error.')
this.videojsDecodeErrors++
await this.load({
...this.currentLoadOptions,
mode: 'p2p-media-loader',
startTime: this.player.currentTime() + 2,
autoplay: true
})
} else {
this.maybeFallbackToWebVideo()
}
}
private async maybeFallbackToWebVideo () {
if (this.currentLoadOptions.mode === 'web-video') {
this.player.peertube().displayFatalError()
return
}
logger.info('Fallback to web-video.')
await this.load({
...this.currentLoadOptions,
mode: 'web-video',
startTime: this.player.currentTime(),
autoplay: true
})
}
getVideojsOptions (): videojs.PlayerOptions {
const html5 = {
preloadTextTracks: false,
// Prevent a bug on iOS where the text tracks added by peertube plugin are removed on play
// See https://github.com/Chocobozzz/PeerTube/issues/6351
nativeTextTracks: false
}
const plugins: VideoJSPluginOptions = {
peertube: {
hasAutoplay: () => this.getAutoPlayValue(this.currentLoadOptions.autoplay),
videoViewUrl: () => this.currentLoadOptions.videoViewUrl,
videoViewIntervalMs: this.options.videoViewIntervalMs,
authorizationHeader: this.options.authorizationHeader,
videoDuration: () => this.currentLoadOptions.duration,
startTime: () => this.currentLoadOptions.startTime,
stopTime: () => this.currentLoadOptions.stopTime,
videoCaptions: () => this.currentLoadOptions.videoCaptions,
isLive: () => this.currentLoadOptions.isLive,
videoUUID: () => this.currentLoadOptions.videoUUID,
subtitle: () => this.currentLoadOptions.subtitle,
videoRatio: () => this.currentLoadOptions.videoRatio,
poster: () => this.currentLoadOptions.poster,
autoPlayerRatio: this.options.autoPlayerRatio
},
metrics: {
mode: () => this.currentLoadOptions.mode,
metricsUrl: () => this.options.metricsUrl,
metricsInterval: () => this.options.metricsInterval,
videoUUID: () => this.currentLoadOptions.videoUUID
}
}
const controlBarOptionsBuilder = new ControlBarOptionsBuilder({
...this.options,
videoShortUUID: () => this.currentLoadOptions.videoShortUUID,
p2pEnabled: () => this.currentLoadOptions.p2pEnabled,
nextVideo: () => this.currentLoadOptions.nextVideo,
previousVideo: () => this.currentLoadOptions.previousVideo
})
const videojsOptions = {
html5,
// We don't use text track settings for now
textTrackSettings: false as any, // FIXME: typings
controls: this.options.controls !== undefined ? this.options.controls : true,
loop: this.options.loop !== undefined ? this.options.loop : false,
muted: this.options.muted !== undefined
? this.options.muted
: undefined, // Undefined so the player knows it has to check the local storage
autoplay: this.getAutoPlayValue(this.currentLoadOptions.autoplay),
poster: this.currentLoadOptions.poster,
inactivityTimeout: this.options.inactivityTimeout,
playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
plugins,
controlBar: {
children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings
},
language: this.options.language && !isDefaultLocale(this.options.language)
? this.options.language
: undefined
}
return videojsOptions
}
private getAutoPlayValue (autoplay: boolean): videojs.Autoplay {
if (autoplay !== true) return false
return this.currentLoadOptions.forceAutoplay
? 'any'
: 'play'
}
private displayNotificationWhenOffline () {
const offlineNotificationElem = document.createElement('div')
offlineNotificationElem.classList.add('vjs-peertube-offline-notification')
offlineNotificationElem.innerText = this.player.localize('You seem to be offline and the video may not work')
let offlineNotificationElemAdded = false
const handleOnline = () => {
if (!offlineNotificationElemAdded) return
this.player.el().removeChild(offlineNotificationElem)
offlineNotificationElemAdded = false
logger.info('The browser is online')
}
const handleOffline = () => {
if (offlineNotificationElemAdded) return
this.player.el().appendChild(offlineNotificationElem)
offlineNotificationElemAdded = true
logger.info('The browser is offline')
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
this.player.on('dispose', () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
}
private getContextMenuOptions () {
const content = () => {
const self = this
const player = this.player
const shortUUID = self.currentLoadOptions.videoShortUUID
const isLoopEnabled = player.options_['loop']
const items = [
{
icon: 'repeat',
label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''),
listener: function () {
player.options_['loop'] = !isLoopEnabled
}
},
{
label: player.localize('Copy the video URL'),
listener: function () {
copyToClipboard(buildVideoLink({ shortUUID }))
}
},
{
label: player.localize('Copy the video URL at the current time'),
listener: function () {
const url = buildVideoLink({ shortUUID })
copyToClipboard(decorateVideoLink({ url, startTime: player.currentTime() }))
}
},
{
icon: 'code',
label: player.localize('Copy embed code'),
listener: () => {
copyToClipboard(buildVideoOrPlaylistEmbed({
embedUrl: self.currentLoadOptions.embedUrl,
embedTitle: self.currentLoadOptions.embedTitle
}))
}
}
]
items.push({
icon: 'info',
label: player.localize('Stats for nerds'),
listener: () => {
player.stats().show()
}
})
return items.map(i => ({
...i,
label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
}))
}
return { content }
}
}