]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Fast forward on HLS decode error
authorChocobozzz <me@florianbigard.com>
Wed, 2 Feb 2022 10:16:23 +0000 (11:16 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 2 Feb 2022 10:26:18 +0000 (11:26 +0100)
client/src/app/+videos/+video-watch/video-watch.component.ts
client/src/assets/player/index.ts [new file with mode: 0644]
client/src/assets/player/p2p-media-loader/hls-plugin.ts
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/peertube-player-options-builder.ts [new file with mode: 0644]
client/src/assets/player/peertube-plugin.ts
client/src/assets/player/peertube-videojs-typings.ts
client/src/assets/player/webtorrent/webtorrent-plugin.ts
client/src/standalone/videos/embed.ts
scripts/i18n/create-custom-files.ts

index 1ee18f52f0d2b92ba25cb562a63ca34f5974c37d..1f45c4d2653198fc86983c4ea61c36162560c0af 100644 (file)
@@ -33,7 +33,6 @@ import {
   VideoPrivacy,
   VideoState
 } from '@shared/models'
-import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage'
 import {
   CustomizationOptions,
   P2PMediaLoaderOptions,
@@ -41,7 +40,8 @@ import {
   PeertubePlayerManagerOptions,
   PlayerMode,
   videojs
-} from '../../../assets/player/peertube-player-manager'
+} from '../../../assets/player'
+import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage'
 import { environment } from '../../../environments/environment'
 import { VideoWatchPlaylistComponent } from './shared'
 
@@ -612,7 +612,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         videoCaptions: playerCaptions,
 
         videoShortUUID: video.shortUUID,
-        videoUUID: video.uuid
+        videoUUID: video.uuid,
+
+        errorNotifier: (message: string) => this.notifier.error(message)
       },
 
       webtorrent: {
diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts
new file mode 100644 (file)
index 0000000..e2a6ccf
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './peertube-player-manager'
+export * from './peertube-player-options-builder'
index 640858025c6b71fdce3d7057eff3ae403cfea73e..ae31bcfe176fc78ece41142170c7c36c9df4843a 100644 (file)
@@ -174,6 +174,12 @@ class Html5Hlsjs {
   dispose () {
     this.videoElement.removeEventListener('play', this.handlers.play)
 
+    // FIXME: https://github.com/video-dev/hls.js/issues/4092
+    const untypedHLS = this.hls as any
+    untypedHLS.log = untypedHLS.warn = () => {
+      // empty
+    }
+
     this.hls.destroy()
   }
 
index b9a289aa0fe13e48943e50c776e037f2645980fe..2ef42a961c8c04f06d7872593215440e35b3bd4c 100644 (file)
@@ -24,28 +24,12 @@ import './mobile/peertube-mobile-plugin'
 import './mobile/peertube-mobile-buttons'
 import './hotkeys/peertube-hotkeys-plugin'
 import videojs from 'video.js'
-import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
 import { PluginsManager } from '@root-helpers/plugins-manager'
-import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
-import { isDefaultLocale } from '@shared/core-utils/i18n'
-import { VideoFile } from '@shared/models'
-import { copyToClipboard } from '../../root-helpers/utils'
-import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
-import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
-import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
-import { getAverageBandwidthInStore, saveAverageBandwidth } from './peertube-player-local-storage'
-import {
-  NextPreviousVideoButtonOptions,
-  P2PMediaLoaderPluginOptions,
-  PeerTubeLinkButtonOptions,
-  PlayerNetworkInfo,
-  PlaylistPluginOptions,
-  UserWatching,
-  VideoJSCaption,
-  VideoJSPluginOptions
-} from './peertube-videojs-typings'
+import { saveAverageBandwidth } from './peertube-player-local-storage'
+import { CommonOptions, PeertubePlayerManagerOptions, PeertubePlayerOptionsBuilder, PlayerMode } from './peertube-player-options-builder'
+import { PlayerNetworkInfo } from './peertube-videojs-typings'
 import { TranslationsManager } from './translations-manager'
-import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isMobile, isSafari } from './utils'
+import { isMobile } from './utils'
 
 // Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
 (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
@@ -56,112 +40,49 @@ 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_ = ' '
 
-export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
-
-export type WebtorrentOptions = {
-  videoFiles: VideoFile[]
-}
-
-export type P2PMediaLoaderOptions = {
-  playlistUrl: string
-  segmentsSha256Url: string
-  trackerAnnounce: string[]
-  redundancyBaseUrls: string[]
-  videoFiles: VideoFile[]
-}
-
-export interface CustomizationOptions {
-  startTime: number | string
-  stopTime: number | string
-
-  controls?: boolean
-  muted?: boolean
-  loop?: boolean
-  subtitle?: string
-  resume?: string
-
-  peertubeLink: boolean
-}
-
-export interface CommonOptions extends CustomizationOptions {
-  playerElement: HTMLVideoElement
-  onPlayerElementChange: (element: HTMLVideoElement) => void
-
-  autoplay: boolean
-  p2pEnabled: boolean
-
-  nextVideo?: () => void
-  hasNextVideo?: () => boolean
-
-  previousVideo?: () => void
-  hasPreviousVideo?: () => boolean
-
-  playlist?: PlaylistPluginOptions
-
-  videoDuration: number
-  enableHotkeys: boolean
-  inactivityTimeout: number
-  poster: string
-
-  theaterButton: boolean
-  captions: boolean
-
-  videoViewUrl: string
-  embedUrl: string
-  embedTitle: string
-
-  isLive: boolean
-
-  language?: string
-
-  videoCaptions: VideoJSCaption[]
-
-  videoUUID: string
-  videoShortUUID: string
-
-  userWatching?: UserWatching
-
-  serverUrl: string
-}
-
-export type PeertubePlayerManagerOptions = {
-  common: CommonOptions
-  webtorrent: WebtorrentOptions
-  p2pMediaLoader?: P2PMediaLoaderOptions
-
-  pluginsManager: PluginsManager
-}
-
 export class PeertubePlayerManager {
   private static playerElementClassName: string
   private static onPlayerChange: (player: videojs.Player) => void
   private static alreadyPlayed = false
   private static pluginsManager: PluginsManager
 
+  private static videojsDecodeErrors = 0
+
+  private static p2pMediaLoaderModule: any
+
   static initState () {
-    PeertubePlayerManager.alreadyPlayed = false
+    this.alreadyPlayed = false
   }
 
   static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) {
     this.pluginsManager = options.pluginsManager
 
-    let p2pMediaLoader: any
-
     this.onPlayerChange = onPlayerChange
     this.playerElementClassName = options.common.playerElement.className
 
     if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
     if (mode === 'p2p-media-loader') {
-      [ p2pMediaLoader ] = await Promise.all([
+      const [ p2pMediaLoaderModule ] = await Promise.all([
         import('@peertube/p2p-media-loader-hlsjs'),
         import('./p2p-media-loader/p2p-media-loader-plugin')
       ])
-    }
 
-    const videojsOptions = await this.getVideojsOptions(mode, options, p2pMediaLoader)
+      this.p2pMediaLoaderModule = p2pMediaLoaderModule
+    }
 
     await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
 
+    return this.buildPlayer(mode, options)
+  }
+
+  private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise<videojs.Player> {
+    const videojsOptionsBuilder = new PeertubePlayerOptionsBuilder(mode, options, this.p2pMediaLoaderModule)
+
+    const videojsOptions = await this.pluginsManager.runHook(
+      'filter:internal.player.videojs.options.result',
+      videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed)
+    )
+
     const self = this
     return new Promise(res => {
       videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
@@ -169,27 +90,24 @@ export class PeertubePlayerManager {
 
         let alreadyFallback = false
 
-        player.tech(true).one('error', () => {
-          if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
+        const handleError = () => {
+          if (alreadyFallback) return
           alreadyFallback = true
-        })
 
-        player.one('error', () => {
-          if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
-          alreadyFallback = true
-        })
+          if (mode === 'p2p-media-loader') {
+            self.tryToRecoverHLSError(player.error(), player, options)
+          } else {
+            self.maybeFallbackToWebTorrent(mode, player, options)
+          }
+        }
+
+        player.one('error', () => handleError())
 
         player.one('play', () => {
-          PeertubePlayerManager.alreadyPlayed = true
+          self.alreadyPlayed = true
         })
 
-        self.addContextMenu({
-          mode,
-          player,
-          videoShortUUID: options.common.videoShortUUID,
-          videoEmbedUrl: options.common.embedUrl,
-          videoEmbedTitle: options.common.embedTitle
-        })
+        self.addContextMenu(videojsOptionsBuilder, player, options.common)
 
         if (isMobile()) player.peertubeMobile()
         if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin()
@@ -214,437 +132,77 @@ export class PeertubePlayerManager {
     })
   }
 
-  private static async maybeFallbackToWebTorrent (currentMode: PlayerMode, player: any, options: PeertubePlayerManagerOptions) {
-    if (currentMode === 'webtorrent') return
-
-    console.log('Fallback to webtorrent.')
-
-    const newVideoElement = document.createElement('video')
-    newVideoElement.className = this.playerElementClassName
-
-    // VideoJS wraps our video element inside a div
-    let currentParentPlayerElement = options.common.playerElement.parentNode
-    // Fix on IOS, don't ask me why
-    if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(options.common.playerElement.id).parentNode
-
-    currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
-
-    options.common.playerElement = newVideoElement
-    options.common.onPlayerElementChange(newVideoElement)
-
-    player.dispose()
-
-    await import('./webtorrent/webtorrent-plugin')
-
-    const mode = 'webtorrent'
-    const videojsOptions = await this.getVideojsOptions(mode, options)
+  private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) {
+    if (err.code === 3) { // Decode error
 
-    const self = this
-    videojs(newVideoElement, videojsOptions, function (this: videojs.Player) {
-      const player = this
-
-      self.addContextMenu({
-        mode,
-        player,
-        videoShortUUID: options.common.videoShortUUID,
-        videoEmbedUrl: options.common.embedUrl,
-        videoEmbedTitle: options.common.embedTitle
-      })
-
-      PeertubePlayerManager.onPlayerChange(player)
-    })
-  }
-
-  private static async getVideojsOptions (
-    mode: PlayerMode,
-    options: PeertubePlayerManagerOptions,
-    p2pMediaLoaderModule?: any
-  ): Promise<videojs.PlayerOptions> {
-    const commonOptions = options.common
-    const isHLS = mode === 'p2p-media-loader'
-
-    let autoplay = this.getAutoPlayValue(commonOptions.autoplay)
-    const html5 = {
-      preloadTextTracks: false
-    }
-
-    const plugins: VideoJSPluginOptions = {
-      peertube: {
-        mode,
-        autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
-        videoViewUrl: commonOptions.videoViewUrl,
-        videoDuration: commonOptions.videoDuration,
-        userWatching: commonOptions.userWatching,
-        subtitle: commonOptions.subtitle,
-        videoCaptions: commonOptions.videoCaptions,
-        stopTime: commonOptions.stopTime,
-        isLive: commonOptions.isLive,
-        videoUUID: commonOptions.videoUUID
+      // Display a notification to user
+      if (this.videojsDecodeErrors === 0) {
+        options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.'))
       }
-    }
-
-    if (commonOptions.playlist) {
-      plugins.playlist = commonOptions.playlist
-    }
-
-    if (isHLS) {
-      const { hlsjs } = PeertubePlayerManager.addP2PMediaLoaderOptions(plugins, options, p2pMediaLoaderModule)
-
-      Object.assign(html5, hlsjs.html5)
-    }
-
-    if (mode === 'webtorrent') {
-      PeertubePlayerManager.addWebTorrentOptions(plugins, options)
-
-      // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
-      autoplay = false
-    }
-
-    const videojsOptions = {
-      html5,
-
-      // We don't use text track settings for now
-      textTrackSettings: false as any, // FIXME: typings
-      controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
-      loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
-
-      muted: commonOptions.muted !== undefined
-        ? commonOptions.muted
-        : undefined, // Undefined so the player knows it has to check the local storage
-
-      autoplay: this.getAutoPlayValue(autoplay),
-
-      poster: commonOptions.poster,
-      inactivityTimeout: commonOptions.inactivityTimeout,
-      playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
-
-      plugins,
-
-      controlBar: {
-        children: this.getControlBarChildren(mode, {
-          videoShortUUID: commonOptions.videoShortUUID,
-          p2pEnabled: commonOptions.p2pEnabled,
-
-          captions: commonOptions.captions,
-          peertubeLink: commonOptions.peertubeLink,
-          theaterButton: commonOptions.theaterButton,
-
-          nextVideo: commonOptions.nextVideo,
-          hasNextVideo: commonOptions.hasNextVideo,
 
-          previousVideo: commonOptions.previousVideo,
-          hasPreviousVideo: commonOptions.hasPreviousVideo
-        }) as any // FIXME: typings
+      if (this.videojsDecodeErrors === 20) {
+        this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options)
+        return
       }
-    }
-
-    if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
-      Object.assign(videojsOptions, { language: commonOptions.language })
-    }
-
-    return this.pluginsManager.runHook('filter:internal.player.videojs.options.result', videojsOptions)
-  }
-
-  private static addP2PMediaLoaderOptions (
-    plugins: VideoJSPluginOptions,
-    options: PeertubePlayerManagerOptions,
-    p2pMediaLoaderModule: any
-  ) {
-    const p2pMediaLoaderOptions = options.p2pMediaLoader
-    const commonOptions = options.common
-
-    const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
-                                                 .filter(t => t.startsWith('ws'))
-
-    const redundancyUrlManager = new RedundancyUrlManager(options.p2pMediaLoader.redundancyBaseUrls)
-
-    const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
-      redundancyUrlManager,
-      type: 'application/x-mpegURL',
-      startTime: commonOptions.startTime,
-      src: p2pMediaLoaderOptions.playlistUrl
-    }
 
-    let consumeOnly = false
-    if ((navigator as any)?.connection?.type === 'cellular') {
-      console.log('We are on a cellular connection: disabling seeding.')
-      consumeOnly = true
-    }
-
-    const p2pMediaLoaderConfig: HlsJsEngineSettings = {
-      loader: {
-        trackerAnnounce,
-        segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url, options.common.isLive),
-        rtcConfig: getRtcConfig(),
-        requiredSegmentsPriority: 1,
-        simultaneousHttpDownloads: 1,
-        segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1),
-        useP2P: commonOptions.p2pEnabled,
-        consumeOnly
-      },
-      segments: {
-        swarmId: p2pMediaLoaderOptions.playlistUrl
-      }
-    }
-
-    const hlsjs = {
-      levelLabelHandler: (level: { height: number, width: number }) => {
-        const resolution = Math.min(level.height || 0, level.width || 0)
+      console.log('Fast forwarding HLS to recover from an error.')
 
-        const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution)
-        // We don't have files for live videos
-        if (!file) return level.height
+      this.videojsDecodeErrors++
 
-        let label = file.resolution.label
-        if (file.fps >= 50) label += file.fps
+      options.common.startTime = currentPlayer.currentTime() + 2
+      options.common.autoplay = true
+      this.rebuildAndUpdateVideoElement(currentPlayer, options.common)
 
-        return label
-      },
-      html5: {
-        hlsjsConfig: this.getHLSOptions(p2pMediaLoaderModule, p2pMediaLoaderConfig)
-      }
+      const newPlayer = await this.buildPlayer('p2p-media-loader', options)
+      this.onPlayerChange(newPlayer)
+    } else {
+      this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options)
     }
-
-    const toAssign = { p2pMediaLoader, hlsjs }
-    Object.assign(plugins, toAssign)
-
-    return toAssign
   }
 
-  private static getHLSOptions (p2pMediaLoaderModule: any, p2pMediaLoaderConfig: HlsJsEngineSettings) {
-    const base = {
-      capLevelToPlayerSize: true,
-      autoStartLoad: false,
-      liveSyncDurationCount: 5,
-
-      loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
-    }
-
-    const averageBandwidth = getAverageBandwidthInStore()
-    if (!averageBandwidth) return base
-
-    return {
-      ...base,
-
-      abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s
-      startLevel: -1,
-      testBandwidth: false,
-      debug: false
-    }
-  }
-
-  private static addWebTorrentOptions (plugins: VideoJSPluginOptions, options: PeertubePlayerManagerOptions) {
-    const commonOptions = options.common
-    const webtorrentOptions = options.webtorrent
-    const p2pMediaLoaderOptions = options.p2pMediaLoader
-
-    const autoplay = this.getAutoPlayValue(commonOptions.autoplay) === 'play'
-
-    const webtorrent = {
-      autoplay,
-      playerRefusedP2P: commonOptions.p2pEnabled === false,
-      videoDuration: commonOptions.videoDuration,
-      playerElement: commonOptions.playerElement,
-      videoFiles: webtorrentOptions.videoFiles.length !== 0
-        ? webtorrentOptions.videoFiles
-        // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode
-        : p2pMediaLoaderOptions?.videoFiles || [],
-      startTime: commonOptions.startTime
-    }
-
-    Object.assign(plugins, { webtorrent })
-  }
-
-  private static getControlBarChildren (mode: PlayerMode, options: {
-    p2pEnabled: boolean
-    videoShortUUID: string
-
-    peertubeLink: boolean
-    theaterButton: boolean
-    captions: boolean
-
-    nextVideo?: () => void
-    hasNextVideo?: () => boolean
-
-    previousVideo?: () => void
-    hasPreviousVideo?: () => boolean
-  }) {
-    const settingEntries = []
-    const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
-
-    // Keep an order
-    settingEntries.push('playbackRateMenuButton')
-    if (options.captions === true) settingEntries.push('captionsButton')
-    settingEntries.push('resolutionMenuButton')
-
-    const children = {}
-
-    if (options.previousVideo) {
-      const buttonOptions: NextPreviousVideoButtonOptions = {
-        type: 'previous',
-        handler: options.previousVideo,
-        isDisabled: () => {
-          if (!options.hasPreviousVideo) return false
-
-          return !options.hasPreviousVideo()
-        }
-      }
-
-      Object.assign(children, {
-        previousVideoButton: buttonOptions
-      })
-    }
-
-    Object.assign(children, { playToggle: {} })
-
-    if (options.nextVideo) {
-      const buttonOptions: NextPreviousVideoButtonOptions = {
-        type: 'next',
-        handler: options.nextVideo,
-        isDisabled: () => {
-          if (!options.hasNextVideo) return false
-
-          return !options.hasNextVideo()
-        }
-      }
-
-      Object.assign(children, {
-        nextVideoButton: buttonOptions
-      })
+  private static async maybeFallbackToWebTorrent (
+    currentMode: PlayerMode,
+    currentPlayer: videojs.Player,
+    options: PeertubePlayerManagerOptions
+  ) {
+    if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') {
+      currentPlayer.peertube().displayFatalError()
+      return
     }
 
-    Object.assign(children, {
-      currentTimeDisplay: {},
-      timeDivider: {},
-      durationDisplay: {},
-      liveDisplay: {},
-
-      flexibleWidthSpacer: {},
-      progressControl: {
-        children: {
-          seekBar: {
-            children: {
-              [loadProgressBar]: {},
-              mouseTimeDisplay: {},
-              playProgressBar: {}
-            }
-          }
-        }
-      },
-
-      p2PInfoButton: {
-        p2pEnabled: options.p2pEnabled
-      },
-
-      muteToggle: {},
-      volumeControl: {},
-
-      settingsButton: {
-        setup: {
-          maxHeightOffset: 40
-        },
-        entries: settingEntries
-      }
-    })
-
-    if (options.peertubeLink === true) {
-      Object.assign(children, {
-        peerTubeLinkButton: { shortUUID: options.videoShortUUID } as PeerTubeLinkButtonOptions
-      })
-    }
+    console.log('Fallback to webtorrent.')
 
-    if (options.theaterButton === true) {
-      Object.assign(children, {
-        theaterButton: {}
-      })
-    }
+    this.rebuildAndUpdateVideoElement(currentPlayer, options.common)
 
-    Object.assign(children, {
-      fullscreenToggle: {}
-    })
+    await import('./webtorrent/webtorrent-plugin')
 
-    return children
+    const newPlayer = await this.buildPlayer('webtorrent', options)
+    this.onPlayerChange(newPlayer)
   }
 
-  private static addContextMenu (options: {
-    mode: PlayerMode
-    player: videojs.Player
-    videoShortUUID: string
-    videoEmbedUrl: string
-    videoEmbedTitle: string
-  }) {
-    const { mode, player, videoEmbedTitle, videoEmbedUrl, videoShortUUID } = options
-
-    const content = () => {
-      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: videoShortUUID }))
-          }
-        },
-        {
-          label: player.localize('Copy the video URL at the current time'),
-          listener: function (this: videojs.Player) {
-            const url = buildVideoLink({ shortUUID: videoShortUUID })
+  private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) {
+    const newVideoElement = document.createElement('video')
+    newVideoElement.className = this.playerElementClassName
 
-            copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() }))
-          }
-        },
-        {
-          icon: 'code',
-          label: player.localize('Copy embed code'),
-          listener: () => {
-            copyToClipboard(buildVideoOrPlaylistEmbed(videoEmbedUrl, videoEmbedTitle))
-          }
-        }
-      ]
+    // VideoJS wraps our video element inside a div
+    let currentParentPlayerElement = commonOptions.playerElement.parentNode
+    // Fix on IOS, don't ask me why
+    if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode
 
-      if (mode === 'webtorrent') {
-        items.push({
-          label: player.localize('Copy magnet URI'),
-          listener: function (this: videojs.Player) {
-            copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
-          }
-        })
-      }
+    currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
 
-      items.push({
-        icon: 'info',
-        label: player.localize('Stats for nerds'),
-        listener: () => {
-          player.stats().show()
-        }
-      })
+    commonOptions.playerElement = newVideoElement
+    commonOptions.onPlayerElementChange(newVideoElement)
 
-      return items.map(i => ({
-        ...i,
-        label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
-      }))
-    }
+    player.dispose()
 
-    // adding the menu
-    player.contextmenuUI({ content })
+    return newVideoElement
   }
 
-  private static getAutoPlayValue (autoplay: any) {
-    if (autoplay !== true) return autoplay
-
-    // On first play, disable autoplay to avoid issues
-    // But if the player already played videos, we can safely autoplay next ones
-    if (isIOS() || isSafari()) {
-      return PeertubePlayerManager.alreadyPlayed ? 'play' : false
-    }
+  private static addContextMenu (optionsBuilder: PeertubePlayerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) {
+    const options = optionsBuilder.getContextMenuOptions(player, commonOptions)
 
-    return 'play'
+    player.contextmenuUI(options)
   }
 }
 
diff --git a/client/src/assets/player/peertube-player-options-builder.ts b/client/src/assets/player/peertube-player-options-builder.ts
new file mode 100644 (file)
index 0000000..901f6cd
--- /dev/null
@@ -0,0 +1,489 @@
+import videojs from 'video.js'
+import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
+import { PluginsManager } from '@root-helpers/plugins-manager'
+import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
+import { isDefaultLocale } from '@shared/core-utils/i18n'
+import { VideoFile } from '@shared/models'
+import { copyToClipboard } from '../../root-helpers/utils'
+import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
+import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
+import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
+import { getAverageBandwidthInStore } from './peertube-player-local-storage'
+import {
+  NextPreviousVideoButtonOptions,
+  P2PMediaLoaderPluginOptions,
+  PeerTubeLinkButtonOptions,
+  PlaylistPluginOptions,
+  UserWatching,
+  VideoJSCaption,
+  VideoJSPluginOptions
+} from './peertube-videojs-typings'
+import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
+
+export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
+
+export type WebtorrentOptions = {
+  videoFiles: VideoFile[]
+}
+
+export type P2PMediaLoaderOptions = {
+  playlistUrl: string
+  segmentsSha256Url: string
+  trackerAnnounce: string[]
+  redundancyBaseUrls: string[]
+  videoFiles: VideoFile[]
+}
+
+export interface CustomizationOptions {
+  startTime: number | string
+  stopTime: number | string
+
+  controls?: boolean
+  muted?: boolean
+  loop?: boolean
+  subtitle?: string
+  resume?: string
+
+  peertubeLink: boolean
+}
+
+export interface CommonOptions extends CustomizationOptions {
+  playerElement: HTMLVideoElement
+  onPlayerElementChange: (element: HTMLVideoElement) => void
+
+  autoplay: boolean
+  p2pEnabled: boolean
+
+  nextVideo?: () => void
+  hasNextVideo?: () => boolean
+
+  previousVideo?: () => void
+  hasPreviousVideo?: () => boolean
+
+  playlist?: PlaylistPluginOptions
+
+  videoDuration: number
+  enableHotkeys: boolean
+  inactivityTimeout: number
+  poster: string
+
+  theaterButton: boolean
+  captions: boolean
+
+  videoViewUrl: string
+  embedUrl: string
+  embedTitle: string
+
+  isLive: boolean
+
+  language?: string
+
+  videoCaptions: VideoJSCaption[]
+
+  videoUUID: string
+  videoShortUUID: string
+
+  userWatching?: UserWatching
+
+  serverUrl: string
+
+  errorNotifier: (message: string) => void
+}
+
+export type PeertubePlayerManagerOptions = {
+  common: CommonOptions
+  webtorrent: WebtorrentOptions
+  p2pMediaLoader?: P2PMediaLoaderOptions
+
+  pluginsManager: PluginsManager
+}
+
+export class PeertubePlayerOptionsBuilder {
+
+  constructor (
+    private mode: PlayerMode,
+    private options: PeertubePlayerManagerOptions,
+    private p2pMediaLoaderModule?: any
+  ) {
+
+  }
+
+  getVideojsOptions (alreadyPlayed: boolean): videojs.PlayerOptions {
+    const commonOptions = this.options.common
+    const isHLS = this.mode === 'p2p-media-loader'
+
+    let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed)
+    const html5 = {
+      preloadTextTracks: false
+    }
+
+    const plugins: VideoJSPluginOptions = {
+      peertube: {
+        mode: this.mode,
+        autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
+        videoViewUrl: commonOptions.videoViewUrl,
+        videoDuration: commonOptions.videoDuration,
+        userWatching: commonOptions.userWatching,
+        subtitle: commonOptions.subtitle,
+        videoCaptions: commonOptions.videoCaptions,
+        stopTime: commonOptions.stopTime,
+        isLive: commonOptions.isLive,
+        videoUUID: commonOptions.videoUUID
+      }
+    }
+
+    if (commonOptions.playlist) {
+      plugins.playlist = commonOptions.playlist
+    }
+
+    if (isHLS) {
+      const { hlsjs } = this.addP2PMediaLoaderOptions(plugins)
+
+      Object.assign(html5, hlsjs.html5)
+    }
+
+    if (this.mode === 'webtorrent') {
+      this.addWebTorrentOptions(plugins, alreadyPlayed)
+
+      // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
+      autoplay = false
+    }
+
+    const videojsOptions = {
+      html5,
+
+      // We don't use text track settings for now
+      textTrackSettings: false as any, // FIXME: typings
+      controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
+      loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
+
+      muted: commonOptions.muted !== undefined
+        ? commonOptions.muted
+        : undefined, // Undefined so the player knows it has to check the local storage
+
+      autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed),
+
+      poster: commonOptions.poster,
+      inactivityTimeout: commonOptions.inactivityTimeout,
+      playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
+
+      plugins,
+
+      controlBar: {
+        children: this.getControlBarChildren(this.mode, {
+          videoShortUUID: commonOptions.videoShortUUID,
+          p2pEnabled: commonOptions.p2pEnabled,
+
+          captions: commonOptions.captions,
+          peertubeLink: commonOptions.peertubeLink,
+          theaterButton: commonOptions.theaterButton,
+
+          nextVideo: commonOptions.nextVideo,
+          hasNextVideo: commonOptions.hasNextVideo,
+
+          previousVideo: commonOptions.previousVideo,
+          hasPreviousVideo: commonOptions.hasPreviousVideo
+        }) as any // FIXME: typings
+      }
+    }
+
+    if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
+      Object.assign(videojsOptions, { language: commonOptions.language })
+    }
+
+    return videojsOptions
+  }
+
+  private addP2PMediaLoaderOptions (plugins: VideoJSPluginOptions) {
+    const p2pMediaLoaderOptions = this.options.p2pMediaLoader
+    const commonOptions = this.options.common
+
+    const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
+                                                 .filter(t => t.startsWith('ws'))
+
+    const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls)
+
+    const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
+      redundancyUrlManager,
+      type: 'application/x-mpegURL',
+      startTime: commonOptions.startTime,
+      src: p2pMediaLoaderOptions.playlistUrl
+    }
+
+    let consumeOnly = false
+    if ((navigator as any)?.connection?.type === 'cellular') {
+      console.log('We are on a cellular connection: disabling seeding.')
+      consumeOnly = true
+    }
+
+    const p2pMediaLoaderConfig: HlsJsEngineSettings = {
+      loader: {
+        trackerAnnounce,
+        segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive),
+        rtcConfig: getRtcConfig(),
+        requiredSegmentsPriority: 1,
+        simultaneousHttpDownloads: 1,
+        segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1),
+        useP2P: commonOptions.p2pEnabled,
+        consumeOnly
+      },
+      segments: {
+        swarmId: p2pMediaLoaderOptions.playlistUrl
+      }
+    }
+
+    const hlsjs = {
+      levelLabelHandler: (level: { height: number, width: number }) => {
+        const resolution = Math.min(level.height || 0, level.width || 0)
+
+        const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution)
+        // We don't have files for live videos
+        if (!file) return level.height
+
+        let label = file.resolution.label
+        if (file.fps >= 50) label += file.fps
+
+        return label
+      },
+      html5: {
+        hlsjsConfig: this.getHLSOptions(p2pMediaLoaderConfig)
+      }
+    }
+
+    const toAssign = { p2pMediaLoader, hlsjs }
+    Object.assign(plugins, toAssign)
+
+    return toAssign
+  }
+
+  private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) {
+    const base = {
+      capLevelToPlayerSize: true,
+      autoStartLoad: false,
+      liveSyncDurationCount: 5,
+
+      loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
+    }
+
+    const averageBandwidth = getAverageBandwidthInStore()
+    if (!averageBandwidth) return base
+
+    return {
+      ...base,
+
+      abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s
+      startLevel: -1,
+      testBandwidth: false,
+      debug: false
+    }
+  }
+
+  private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) {
+    const commonOptions = this.options.common
+    const webtorrentOptions = this.options.webtorrent
+    const p2pMediaLoaderOptions = this.options.p2pMediaLoader
+
+    const autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) === 'play'
+
+    const webtorrent = {
+      autoplay,
+
+      playerRefusedP2P: commonOptions.p2pEnabled === false,
+      videoDuration: commonOptions.videoDuration,
+      playerElement: commonOptions.playerElement,
+
+      videoFiles: webtorrentOptions.videoFiles.length !== 0
+        ? webtorrentOptions.videoFiles
+        // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode
+        : p2pMediaLoaderOptions?.videoFiles || [],
+
+      startTime: commonOptions.startTime
+    }
+
+    Object.assign(plugins, { webtorrent })
+  }
+
+  private getControlBarChildren (mode: PlayerMode, options: {
+    p2pEnabled: boolean
+    videoShortUUID: string
+
+    peertubeLink: boolean
+    theaterButton: boolean
+    captions: boolean
+
+    nextVideo?: () => void
+    hasNextVideo?: () => boolean
+
+    previousVideo?: () => void
+    hasPreviousVideo?: () => boolean
+  }) {
+    const settingEntries = []
+    const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
+
+    // Keep an order
+    settingEntries.push('playbackRateMenuButton')
+    if (options.captions === true) settingEntries.push('captionsButton')
+    settingEntries.push('resolutionMenuButton')
+
+    const children = {}
+
+    if (options.previousVideo) {
+      const buttonOptions: NextPreviousVideoButtonOptions = {
+        type: 'previous',
+        handler: options.previousVideo,
+        isDisabled: () => {
+          if (!options.hasPreviousVideo) return false
+
+          return !options.hasPreviousVideo()
+        }
+      }
+
+      Object.assign(children, {
+        previousVideoButton: buttonOptions
+      })
+    }
+
+    Object.assign(children, { playToggle: {} })
+
+    if (options.nextVideo) {
+      const buttonOptions: NextPreviousVideoButtonOptions = {
+        type: 'next',
+        handler: options.nextVideo,
+        isDisabled: () => {
+          if (!options.hasNextVideo) return false
+
+          return !options.hasNextVideo()
+        }
+      }
+
+      Object.assign(children, {
+        nextVideoButton: buttonOptions
+      })
+    }
+
+    Object.assign(children, {
+      currentTimeDisplay: {},
+      timeDivider: {},
+      durationDisplay: {},
+      liveDisplay: {},
+
+      flexibleWidthSpacer: {},
+      progressControl: {
+        children: {
+          seekBar: {
+            children: {
+              [loadProgressBar]: {},
+              mouseTimeDisplay: {},
+              playProgressBar: {}
+            }
+          }
+        }
+      },
+
+      p2PInfoButton: {
+        p2pEnabled: options.p2pEnabled
+      },
+
+      muteToggle: {},
+      volumeControl: {},
+
+      settingsButton: {
+        setup: {
+          maxHeightOffset: 40
+        },
+        entries: settingEntries
+      }
+    })
+
+    if (options.peertubeLink === true) {
+      Object.assign(children, {
+        peerTubeLinkButton: { shortUUID: options.videoShortUUID } as PeerTubeLinkButtonOptions
+      })
+    }
+
+    if (options.theaterButton === true) {
+      Object.assign(children, {
+        theaterButton: {}
+      })
+    }
+
+    Object.assign(children, {
+      fullscreenToggle: {}
+    })
+
+    return children
+  }
+
+  private getAutoPlayValue (autoplay: any, alreadyPlayed: boolean) {
+    if (autoplay !== true) return autoplay
+
+    // On first play, disable autoplay to avoid issues
+    // But if the player already played videos, we can safely autoplay next ones
+    if (isIOS() || isSafari()) {
+      return alreadyPlayed ? 'play' : false
+    }
+
+    return 'play'
+  }
+
+  getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) {
+    const content = () => {
+      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: commonOptions.videoShortUUID }))
+          }
+        },
+        {
+          label: player.localize('Copy the video URL at the current time'),
+          listener: function (this: videojs.Player) {
+            const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID })
+
+            copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() }))
+          }
+        },
+        {
+          icon: 'code',
+          label: player.localize('Copy embed code'),
+          listener: () => {
+            copyToClipboard(buildVideoOrPlaylistEmbed(commonOptions.embedUrl, commonOptions.embedTitle))
+          }
+        }
+      ]
+
+      if (this.mode === 'webtorrent') {
+        items.push({
+          label: player.localize('Copy magnet URI'),
+          listener: function (this: videojs.Player) {
+            copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
+          }
+        })
+      }
+
+      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 }
+  }
+}
index fd612dd4f0423a66d51992fcae8f81f9d216ecf1..b5c42d1c555305bad66d53fca0e2a8b992cb0ece 100644 (file)
@@ -122,6 +122,14 @@ class PeerTubePlugin extends Plugin {
     this.alterInactivity()
   }
 
+  displayFatalError () {
+    this.player.addClass('vjs-error-display-enabled')
+  }
+
+  hideFatalError () {
+    this.player.removeClass('vjs-error-display-enabled')
+  }
+
   private initializePlayer () {
     if (isMobile()) this.player.addClass('vjs-is-mobile')
 
index b20ef7a3b70a88f3bf7f159e5f3ee1bfcff21ed2..246f0d390c76984379b6d4b8c40dcba988e37485 100644 (file)
@@ -4,7 +4,7 @@ import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
 import { Html5Hlsjs } from './p2p-media-loader/hls-plugin'
 import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
 import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
-import { PlayerMode } from './peertube-player-manager'
+import { PlayerMode } from './peertube-player-options-builder'
 import { PeerTubePlugin } from './peertube-plugin'
 import { PeerTubeResolutionsPlugin } from './peertube-resolutions-plugin'
 import { PlaylistPlugin } from './playlist/playlist-plugin'
index 16dc7a244e7ffafa7712fa6dfef007ec32c3f097..4bcb2766a66d673f37e713d6b5836f0ec59004fd 100644 (file)
@@ -145,7 +145,7 @@ class WebTorrentPlugin extends Plugin {
     }
 
     // Do not display error to user because we will have multiple fallback
-    this.disableErrorDisplay();
+    this.player.peertube().hideFatalError();
 
     // Hack to "simulate" src link in video.js >= 6
     // Without this, we can't play the video after pausing it
@@ -524,7 +524,7 @@ class WebTorrentPlugin extends Plugin {
     this.torrent = null
 
     // Enable error display now this is our last fallback
-    this.player.one('error', () => this.enableErrorDisplay())
+    this.player.one('error', () => this.player.peertube().displayFatalError())
 
     const httpUrl = this.currentVideoFile.fileUrl
     this.player.src = this.savePlayerSrcFunction
@@ -549,14 +549,6 @@ class WebTorrentPlugin extends Plugin {
     return this.player.trigger('customError', { err })
   }
 
-  private enableErrorDisplay () {
-    this.player.addClass('vjs-error-display-enabled')
-  }
-
-  private disableErrorDisplay () {
-    this.player.removeClass('vjs-error-display-enabled')
-  }
-
   private pickAverageVideoFile () {
     if (this.videoFiles.length === 1) return this.videoFiles[0]
 
index eb8076b98b766d1c45cd3f384b2deacd99a25a48..054f771ab701eff061a3153ba8e9f8054872d85f 100644 (file)
@@ -14,7 +14,7 @@ import {
   VideoPlaylistElement,
   VideoStreamingPlaylistType
 } from '../../../../shared/models'
-import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player/peertube-player-manager'
+import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player'
 import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
 import { TranslationsManager } from '../../assets/player/translations-manager'
 import { isP2PEnabled } from '../../assets/player/utils'
@@ -558,7 +558,11 @@ export class PeerTubeEmbed {
         serverUrl: window.location.origin,
         language: navigator.language,
         embedUrl: window.location.origin + videoInfo.embedPath,
-        embedTitle: videoInfo.name
+        embedTitle: videoInfo.name,
+
+        errorNotifier: () => {
+          // Empty, we don't have a notifier in the embed
+        }
       },
 
       webtorrent: {
@@ -664,7 +668,6 @@ export class PeerTubeEmbed {
       this.player.dispose()
       this.playerElement = null
       this.displayError('This video is not available because the remote instance is not responding.', translations)
-
     }
   }
 
index 248a5b038656b3aebd4e25aa954081e51b0db0b1..bf3dfa1c9913f684ae50e10995510181c0a78dad 100755 (executable)
@@ -55,7 +55,8 @@ const playerKeys = {
   'Playlist: {1}': 'Playlist: {1}',
   'disabled': 'disabled',
   '  off': '  off',
-  'Player mode': 'Player mode'
+  'Player mode': 'Player mode',
+  'The video failed to play, will try to fast forward.': 'The video failed to play, will try to fast forward.'
 }
 Object.assign(playerKeys, videojs)