Chocobozzz/PeerTube

View on GitHub
client/src/assets/player/peertube-player.ts

Summary

Maintainability
D
2 days
Test Coverage
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 }
  }
}