Refractor videojs player
authorChocobozzz <me@florianbigard.com>
Wed, 23 Jan 2019 14:36:45 +0000 (15:36 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 11 Feb 2019 08:13:02 +0000 (09:13 +0100)
Add fake p2p-media-loader plugin

34 files changed:
client/package.json
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/assets/player/p2p-media-loader-plugin.ts [new file with mode: 0644]
client/src/assets/player/peertube-chunk-store.ts [deleted file]
client/src/assets/player/peertube-link-button.ts [deleted file]
client/src/assets/player/peertube-load-progress-bar.ts [deleted file]
client/src/assets/player/peertube-player-manager.ts [new file with mode: 0644]
client/src/assets/player/peertube-player.ts [deleted file]
client/src/assets/player/peertube-plugin.ts [new file with mode: 0644]
client/src/assets/player/peertube-videojs-plugin.ts [deleted file]
client/src/assets/player/peertube-videojs-typings.ts
client/src/assets/player/resolution-menu-button.ts [deleted file]
client/src/assets/player/resolution-menu-item.ts [deleted file]
client/src/assets/player/settings-menu-button.ts [deleted file]
client/src/assets/player/settings-menu-item.ts [deleted file]
client/src/assets/player/theater-button.ts [deleted file]
client/src/assets/player/video-renderer.ts [deleted file]
client/src/assets/player/videojs-components/p2p-info-button.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/peertube-link-button.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/peertube-load-progress-bar.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/resolution-menu-button.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/resolution-menu-item.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/settings-menu-button.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/settings-menu-item.ts [new file with mode: 0644]
client/src/assets/player/videojs-components/theater-button.ts [new file with mode: 0644]
client/src/assets/player/webtorrent-info-button.ts [deleted file]
client/src/assets/player/webtorrent-plugin.ts [new file with mode: 0644]
client/src/assets/player/webtorrent/peertube-chunk-store.ts [new file with mode: 0644]
client/src/assets/player/webtorrent/video-renderer.ts [new file with mode: 0644]
client/src/standalone/videos/embed.ts
client/src/tsconfig.app.json
client/yarn.lock
scripts/dev/server.sh
server/middlewares/csp.ts

index 31fc778876fb76a77c8fdaaada38ae3231e947f1..9da7c102575211bd4a42d6dad5d3de56d678817e 100644 (file)
@@ -85,6 +85,7 @@
     "@ngx-loading-bar/router": "^3.0.0",
     "@ngx-meta/core": "^6.0.0-rc.1",
     "@ngx-translate/i18n-polyfill": "^1.0.0",
+    "@streamroot/videojs-hlsjs-plugin": "^1.0.7",
     "@types/core-js": "^2.5.0",
     "@types/jasmine": "^2.8.7",
     "@types/jasminewd2": "^2.0.3",
     "ngx-qrcode2": "^0.0.9",
     "node-sass": "^4.9.3",
     "npm-font-source-sans-pro": "^1.0.2",
+    "p2p-media-loader-hlsjs": "^0.3.0",
     "path-browserify": "^1.0.0",
     "primeng": "^7.0.0",
     "process": "^0.11.10",
     "typescript": "3.1.6",
     "video.js": "^7",
     "videojs-contextmenu-ui": "^5.0.0",
+    "videojs-contrib-quality-levels": "^2.0.9",
     "videojs-dock": "^2.0.2",
     "videojs-hotkeys": "^0.2.21",
     "webpack-bundle-analyzer": "^3.0.2",
index ee504bc58c8219320c9e8c656703e1c8d33b5ab3..6e38af19593a0b9f085ee7002b3a5ac1d0b48741 100644 (file)
@@ -7,14 +7,9 @@ import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-supp
 import { MetaService } from '@ngx-meta/core'
 import { Notifier, ServerService } from '@app/core'
 import { forkJoin, Subscription } from 'rxjs'
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import videojs from 'video.js'
-import 'videojs-hotkeys'
 import { Hotkey, HotkeysService } from 'angular2-hotkeys'
 import * as WebTorrent from 'webtorrent'
 import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
-import '../../../assets/player/peertube-videojs-plugin'
 import { AuthService, ConfirmService } from '../../core'
 import { RestExtractor, VideoBlacklistService } from '../../shared'
 import { VideoDetails } from '../../shared/video/video-details.model'
@@ -24,12 +19,11 @@ import { VideoReportComponent } from './modal/video-report.component'
 import { VideoShareComponent } from './modal/video-share.component'
 import { VideoBlacklistComponent } from './modal/video-blacklist.component'
 import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
-import { addContextMenu, getVideojsOptions, loadLocaleInVideoJS } from '../../../assets/player/peertube-player'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { environment } from '../../../environments/environment'
-import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
 import { VideoCaptionService } from '@app/shared/video-caption'
 import { MarkdownService } from '@app/shared/renderer'
+import { PeertubePlayerManager } from '../../../assets/player/peertube-player-manager'
 
 @Component({
   selector: 'my-video-watch',
@@ -46,7 +40,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
   @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
 
-  player: videojs.Player
+  player: any
   playerElement: HTMLVideoElement
   userRating: UserVideoRateType = null
   video: VideoDetails = null
@@ -61,7 +55,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   remoteServerDown = false
   hotkeys: Hotkey[]
 
-  private videojsLocaleLoaded = false
   private paramsSub: Subscription
 
   constructor (
@@ -402,41 +395,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       src: environment.apiUrl + c.captionPath
     }))
 
-    const videojsOptions = getVideojsOptions({
-      autoplay: this.isAutoplay(),
-      inactivityTimeout: 2500,
-      videoFiles: this.video.files,
-      videoCaptions: playerCaptions,
-      playerElement: this.playerElement,
-      videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
-      videoDuration: this.video.duration,
-      enableHotkeys: true,
-      peertubeLink: false,
-      poster: this.video.previewUrl,
-      startTime,
-      subtitle: urlOptions.subtitle,
-      theaterMode: true,
-      language: this.localeId,
-
-      userWatching: this.user && this.user.videosHistoryEnabled === true ? {
-        url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
-        authorizationHeader: this.authService.getRequestHeaderValue()
-      } : undefined
-    })
+    const options = {
+      common: {
+        autoplay: this.isAutoplay(),
+        playerElement: this.playerElement,
+        videoDuration: this.video.duration,
+        enableHotkeys: true,
+        inactivityTimeout: 2500,
+        poster: this.video.previewUrl,
+        startTime,
+
+        theaterMode: true,
+        captions: videoCaptions.length !== 0,
+        peertubeLink: false,
+
+        videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
+        embedUrl: this.video.embedUrl,
+
+        language: this.localeId,
+
+        subtitle: urlOptions.subtitle,
 
-    if (this.videojsLocaleLoaded === false) {
-      await loadLocaleInVideoJS(environment.apiUrl, videojs, isOnDevLocale() ? getDevLocale() : this.localeId)
-      this.videojsLocaleLoaded = true
+        userWatching: this.user && this.user.videosHistoryEnabled === true ? {
+          url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
+          authorizationHeader: this.authService.getRequestHeaderValue()
+        } : undefined,
+
+        serverUrl: environment.apiUrl,
+
+        videoCaptions: playerCaptions
+      },
+
+      webtorrent: {
+        videoFiles: this.video.files
+      }
     }
 
-    const self = this
     this.zone.runOutsideAngular(async () => {
-      videojs(this.playerElement, videojsOptions, function (this: videojs.Player) {
-        self.player = this
-        this.on('customError', ({ err }: { err: any }) => self.handleError(err))
-
-        addContextMenu(self.player, self.video.embedUrl)
-      })
+      this.player = await PeertubePlayerManager.initialize('webtorrent', options)
+      this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
     })
 
     this.setVideoDescriptionHTML()
diff --git a/client/src/assets/player/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader-plugin.ts
new file mode 100644 (file)
index 0000000..6d07a2c
--- /dev/null
@@ -0,0 +1,33 @@
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+import { P2PMediaLoaderPluginOptions, VideoJSComponentInterface } from './peertube-videojs-typings'
+
+// videojs-hlsjs-plugin needs videojs in window
+window['videojs'] = videojs
+import '@streamroot/videojs-hlsjs-plugin'
+
+import { initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
+
+// import { Events } from '../p2p-media-loader/p2p-media-loader-core/lib'
+
+const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
+class P2pMediaLoaderPlugin extends Plugin {
+
+  constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) {
+    super(player, options)
+
+    initVideoJsContribHlsJsPlayer(player)
+
+    console.log(options)
+
+    player.src({
+      type: options.type,
+      src: options.src
+    })
+  }
+
+}
+
+videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
+export { P2pMediaLoaderPlugin }
diff --git a/client/src/assets/player/peertube-chunk-store.ts b/client/src/assets/player/peertube-chunk-store.ts
deleted file mode 100644 (file)
index 54cc0ea..0000000
+++ /dev/null
@@ -1,231 +0,0 @@
-// From https://github.com/MinEduTDF/idb-chunk-store
-// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues
-// Thanks @santiagogil and @Feross
-
-import { EventEmitter } from 'events'
-import Dexie from 'dexie'
-
-class ChunkDatabase extends Dexie {
-  chunks: Dexie.Table<{ id: number, buf: Buffer }, number>
-
-  constructor (dbname: string) {
-    super(dbname)
-
-    this.version(1).stores({
-      chunks: 'id'
-    })
-  }
-}
-
-class ExpirationDatabase extends Dexie {
-  databases: Dexie.Table<{ name: string, expiration: number }, number>
-
-  constructor () {
-    super('webtorrent-expiration')
-
-    this.version(1).stores({
-      databases: 'name,expiration'
-    })
-  }
-}
-
-export class PeertubeChunkStore extends EventEmitter {
-  private static readonly BUFFERING_PUT_MS = 1000
-  private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute
-  private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes
-
-  chunkLength: number
-
-  private pendingPut: { id: number, buf: Buffer, cb: Function }[] = []
-  // If the store is full
-  private memoryChunks: { [ id: number ]: Buffer | true } = {}
-  private databaseName: string
-  private putBulkTimeout: any
-  private cleanerInterval: any
-  private db: ChunkDatabase
-  private expirationDB: ExpirationDatabase
-  private readonly length: number
-  private readonly lastChunkLength: number
-  private readonly lastChunkIndex: number
-
-  constructor (chunkLength: number, opts: any) {
-    super()
-
-    this.databaseName = 'webtorrent-chunks-'
-
-    if (!opts) opts = {}
-    if (opts.torrent && opts.torrent.infoHash) this.databaseName += opts.torrent.infoHash
-    else this.databaseName += '-default'
-
-    this.setMaxListeners(100)
-
-    this.chunkLength = Number(chunkLength)
-    if (!this.chunkLength) throw new Error('First argument must be a chunk length')
-
-    this.length = Number(opts.length) || Infinity
-
-    if (this.length !== Infinity) {
-      this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength
-      this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1
-    }
-
-    this.db = new ChunkDatabase(this.databaseName)
-    // Track databases that expired
-    this.expirationDB = new ExpirationDatabase()
-
-    this.runCleaner()
-  }
-
-  put (index: number, buf: Buffer, cb: (err?: Error) => void) {
-    const isLastChunk = (index === this.lastChunkIndex)
-    if (isLastChunk && buf.length !== this.lastChunkLength) {
-      return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength))
-    }
-    if (!isLastChunk && buf.length !== this.chunkLength) {
-      return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength))
-    }
-
-    // Specify we have this chunk
-    this.memoryChunks[index] = true
-
-    // Add it to the pending put
-    this.pendingPut.push({ id: index, buf, cb })
-    // If it's already planned, return
-    if (this.putBulkTimeout) return
-
-    // Plan a future bulk insert
-    this.putBulkTimeout = setTimeout(async () => {
-      const processing = this.pendingPut
-      this.pendingPut = []
-      this.putBulkTimeout = undefined
-
-      try {
-        await this.db.transaction('rw', this.db.chunks, () => {
-          return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf })))
-        })
-      } catch (err) {
-        console.log('Cannot bulk insert chunks. Store them in memory.', { err })
-
-        processing.forEach(p => this.memoryChunks[ p.id ] = p.buf)
-      } finally {
-        processing.forEach(p => p.cb())
-      }
-    }, PeertubeChunkStore.BUFFERING_PUT_MS)
-  }
-
-  get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void {
-    if (typeof opts === 'function') return this.get(index, null, opts)
-
-    // IndexDB could be slow, use our memory index first
-    const memoryChunk = this.memoryChunks[index]
-    if (memoryChunk === undefined) {
-      const err = new Error('Chunk not found') as any
-      err['notFound'] = true
-
-      return process.nextTick(() => cb(err))
-    }
-
-    // Chunk in memory
-    if (memoryChunk !== true) return cb(null, memoryChunk)
-
-    // Chunk in store
-    this.db.transaction('r', this.db.chunks, async () => {
-      const result = await this.db.chunks.get({ id: index })
-      if (result === undefined) return cb(null, new Buffer(0))
-
-      const buf = result.buf
-      if (!opts) return this.nextTick(cb, null, buf)
-
-      const offset = opts.offset || 0
-      const len = opts.length || (buf.length - offset)
-      return cb(null, buf.slice(offset, len + offset))
-    })
-    .catch(err => {
-      console.error(err)
-      return cb(err)
-    })
-  }
-
-  close (cb: (err?: Error) => void) {
-    return this.destroy(cb)
-  }
-
-  async destroy (cb: (err?: Error) => void) {
-    try {
-      if (this.pendingPut) {
-        clearTimeout(this.putBulkTimeout)
-        this.pendingPut = null
-      }
-      if (this.cleanerInterval) {
-        clearInterval(this.cleanerInterval)
-        this.cleanerInterval = null
-      }
-
-      if (this.db) {
-        await this.db.close()
-
-        await this.dropDatabase(this.databaseName)
-      }
-
-      if (this.expirationDB) {
-        await this.expirationDB.close()
-        this.expirationDB = null
-      }
-
-      return cb()
-    } catch (err) {
-      console.error('Cannot destroy peertube chunk store.', err)
-      return cb(err)
-    }
-  }
-
-  private runCleaner () {
-    this.checkExpiration()
-
-    this.cleanerInterval = setInterval(async () => {
-      this.checkExpiration()
-    }, PeertubeChunkStore.CLEANER_INTERVAL_MS)
-  }
-
-  private async checkExpiration () {
-    let databasesToDeleteInfo: { name: string }[] = []
-
-    try {
-      await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => {
-        // Update our database expiration since we are alive
-        await this.expirationDB.databases.put({
-          name: this.databaseName,
-          expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS
-        })
-
-        const now = new Date().getTime()
-        databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray()
-      })
-    } catch (err) {
-      console.error('Cannot update expiration of fetch expired databases.', err)
-    }
-
-    for (const databaseToDeleteInfo of databasesToDeleteInfo) {
-      await this.dropDatabase(databaseToDeleteInfo.name)
-    }
-  }
-
-  private async dropDatabase (databaseName: string) {
-    const dbToDelete = new ChunkDatabase(databaseName)
-    console.log('Destroying IndexDB database %s.', databaseName)
-
-    try {
-      await dbToDelete.delete()
-
-      await this.expirationDB.transaction('rw', this.expirationDB.databases, () => {
-        return this.expirationDB.databases.where({ name: databaseName }).delete()
-      })
-    } catch (err) {
-      console.error('Cannot delete %s.', databaseName, err)
-    }
-  }
-
-  private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
-    process.nextTick(() => cb(err, val), undefined)
-  }
-}
diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts
deleted file mode 100644 (file)
index de9a49d..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { buildVideoLink } from './utils'
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
-class PeerTubeLinkButton extends Button {
-
-  constructor (player: Player, options: any) {
-    super(player, options)
-  }
-
-  createEl () {
-    return this.buildElement()
-  }
-
-  updateHref () {
-    this.el().setAttribute('href', buildVideoLink(this.player().currentTime()))
-  }
-
-  handleClick () {
-    this.player_.pause()
-  }
-
-  private buildElement () {
-    const el = videojsUntyped.dom.createEl('a', {
-      href: buildVideoLink(),
-      innerHTML: 'PeerTube',
-      title: this.player_.localize('Go to the video page'),
-      className: 'vjs-peertube-link',
-      target: '_blank'
-    })
-
-    el.addEventListener('mouseenter', () => this.updateHref())
-
-    return el
-  }
-}
-Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton)
diff --git a/client/src/assets/player/peertube-load-progress-bar.ts b/client/src/assets/player/peertube-load-progress-bar.ts
deleted file mode 100644 (file)
index af276d1..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
-
-class PeerTubeLoadProgressBar extends Component {
-
-  constructor (player: Player, options: any) {
-    super(player, options)
-    this.partEls_ = []
-    this.on(player, 'progress', this.update)
-  }
-
-  createEl () {
-    return super.createEl('div', {
-      className: 'vjs-load-progress',
-      innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
-    })
-  }
-
-  dispose () {
-    this.partEls_ = null
-
-    super.dispose()
-  }
-
-  update () {
-    const torrent = this.player().peertube().getTorrent()
-    if (!torrent) return
-
-    this.el_.style.width = (torrent.progress * 100) + '%'
-  }
-
-}
-
-Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar)
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
new file mode 100644 (file)
index 0000000..9155c06
--- /dev/null
@@ -0,0 +1,388 @@
+import { VideoFile } from '../../../../shared/models/videos'
+// @ts-ignore
+import * as videojs from 'video.js'
+import 'videojs-hotkeys'
+import 'videojs-dock'
+import 'videojs-contextmenu-ui'
+import 'videojs-contrib-quality-levels'
+import './peertube-plugin'
+import './videojs-components/peertube-link-button'
+import './videojs-components/resolution-menu-button'
+import './videojs-components/settings-menu-button'
+import './videojs-components/p2p-info-button'
+import './videojs-components/peertube-load-progress-bar'
+import './videojs-components/theater-button'
+import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings'
+import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
+import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
+import { Engine } from 'p2p-media-loader-hlsjs'
+
+// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
+videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
+// Change Captions to Subtitles/CC
+videojsUntyped.getComponent('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)
+videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
+
+type PlayerMode = 'webtorrent' | 'p2p-media-loader'
+
+type WebtorrentOptions = {
+  videoFiles: VideoFile[]
+}
+
+type P2PMediaLoaderOptions = {
+  playlistUrl: string
+}
+
+type CommonOptions = {
+  playerElement: HTMLVideoElement
+
+  autoplay: boolean
+  videoDuration: number
+  enableHotkeys: boolean
+  inactivityTimeout: number
+  poster: string
+  startTime: number | string
+
+  theaterMode: boolean
+  captions: boolean
+  peertubeLink: boolean
+
+  videoViewUrl: string
+  embedUrl: string
+
+  language?: string
+  controls?: boolean
+  muted?: boolean
+  loop?: boolean
+  subtitle?: string
+
+  videoCaptions: VideoJSCaption[]
+
+  userWatching?: UserWatching
+
+  serverUrl: string
+}
+
+export type PeertubePlayerManagerOptions = {
+  common: CommonOptions,
+  webtorrent?: WebtorrentOptions,
+  p2pMediaLoader?: P2PMediaLoaderOptions
+}
+
+export class PeertubePlayerManager {
+
+  private static videojsLocaleCache: { [ path: string ]: any } = {}
+
+  static getServerTranslations (serverUrl: string, locale: string) {
+    const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
+    // It is the default locale, nothing to translate
+    if (!path) return Promise.resolve(undefined)
+
+    return fetch(path + '/server.json')
+      .then(res => res.json())
+      .catch(err => {
+        console.error('Cannot get server translations', err)
+        return undefined
+      })
+  }
+
+  static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
+    if (mode === 'webtorrent') await import('./webtorrent-plugin')
+    if (mode === 'p2p-media-loader') await import('./p2p-media-loader-plugin')
+
+    const videojsOptions = this.getVideojsOptions(mode, options)
+
+    await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language)
+
+    const self = this
+    return new Promise(res => {
+      videojs(options.common.playerElement, videojsOptions, function (this: any) {
+        const player = this
+
+        self.addContextMenu(mode, player, options.common.embedUrl)
+
+        return res(player)
+      })
+    })
+  }
+
+  private static loadLocaleInVideoJS (serverUrl: string, locale: string) {
+    const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
+    // It is the default locale, nothing to translate
+    if (!path) return Promise.resolve(undefined)
+
+    let p: Promise<any>
+
+    if (PeertubePlayerManager.videojsLocaleCache[path]) {
+      p = Promise.resolve(PeertubePlayerManager.videojsLocaleCache[path])
+    } else {
+      p = fetch(path + '/player.json')
+        .then(res => res.json())
+        .then(json => {
+          PeertubePlayerManager.videojsLocaleCache[path] = json
+          return json
+        })
+        .catch(err => {
+          console.error('Cannot get player translations', err)
+          return undefined
+        })
+    }
+
+    const completeLocale = getCompleteLocale(locale)
+    return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
+  }
+
+  private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
+    const commonOptions = options.common
+    const webtorrentOptions = options.webtorrent
+    const p2pMediaLoaderOptions = options.p2pMediaLoader
+
+    const plugins: VideoJSPluginOptions = {
+      peertube: {
+        autoplay: commonOptions.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
+        videoViewUrl: commonOptions.videoViewUrl,
+        videoDuration: commonOptions.videoDuration,
+        startTime: commonOptions.startTime,
+        userWatching: commonOptions.userWatching,
+        subtitle: commonOptions.subtitle,
+        videoCaptions: commonOptions.videoCaptions
+      }
+    }
+
+    if (p2pMediaLoaderOptions) {
+      const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
+        type: 'application/x-mpegURL',
+        src: p2pMediaLoaderOptions.playlistUrl
+      }
+
+      const config = {
+        segments: {
+          swarmId: 'swarm' // TODO: choose swarm id
+        }
+      }
+      const streamrootHls = {
+        html5: {
+          hlsjsConfig: {
+            liveSyncDurationCount: 7,
+            loader: new Engine(config).createLoaderClass()
+          }
+        }
+      }
+
+      Object.assign(plugins, { p2pMediaLoader, streamrootHls })
+    }
+
+    if (webtorrentOptions) {
+      const webtorrent = {
+        autoplay: commonOptions.autoplay,
+        videoDuration: commonOptions.videoDuration,
+        playerElement: commonOptions.playerElement,
+        videoFiles: webtorrentOptions.videoFiles
+      }
+      Object.assign(plugins, { webtorrent })
+    }
+
+    const videojsOptions = {
+      // We don't use text track settings for now
+      textTrackSettings: false,
+      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
+
+      poster: commonOptions.poster,
+      autoplay: false,
+      inactivityTimeout: commonOptions.inactivityTimeout,
+      playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
+      plugins,
+      controlBar: {
+        children: this.getControlBarChildren(mode, {
+          captions: commonOptions.captions,
+          peertubeLink: commonOptions.peertubeLink,
+          theaterMode: commonOptions.theaterMode
+        })
+      }
+    }
+
+    if (commonOptions.enableHotkeys === true) {
+      Object.assign(videojsOptions.plugins, {
+        hotkeys: {
+          enableVolumeScroll: false,
+          enableModifiersForNumbers: false,
+
+          fullscreenKey: function (event: KeyboardEvent) {
+            // fullscreen with the f key or Ctrl+Enter
+            return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
+          },
+
+          seekStep: function (event: KeyboardEvent) {
+            // mimic VLC seek behavior, and default to 5 (original value is 5).
+            if (event.ctrlKey && event.altKey) {
+              return 5 * 60
+            } else if (event.ctrlKey) {
+              return 60
+            } else if (event.altKey) {
+              return 10
+            } else {
+              return 5
+            }
+          },
+
+          customKeys: {
+            increasePlaybackRateKey: {
+              key: function (event: KeyboardEvent) {
+                return event.key === '>'
+              },
+              handler: function (player: videojs.Player) {
+                player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
+              }
+            },
+            decreasePlaybackRateKey: {
+              key: function (event: KeyboardEvent) {
+                return event.key === '<'
+              },
+              handler: function (player: videojs.Player) {
+                player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
+              }
+            },
+            frameByFrame: {
+              key: function (event: KeyboardEvent) {
+                return event.key === '.'
+              },
+              handler: function (player: videojs.Player) {
+                player.pause()
+                // Calculate movement distance (assuming 30 fps)
+                const dist = 1 / 30
+                player.currentTime(player.currentTime() + dist)
+              }
+            }
+          }
+        }
+      })
+    }
+
+    if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
+      Object.assign(videojsOptions, { language: commonOptions.language })
+    }
+
+    return videojsOptions
+  }
+
+  private static getControlBarChildren (mode: PlayerMode, options: {
+    peertubeLink: boolean
+    theaterMode: boolean,
+    captions: 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 = {
+      'playToggle': {},
+      'currentTimeDisplay': {},
+      'timeDivider': {},
+      'durationDisplay': {},
+      'liveDisplay': {},
+
+      'flexibleWidthSpacer': {},
+      'progressControl': {
+        children: {
+          'seekBar': {
+            children: {
+              [loadProgressBar]: {},
+              'mouseTimeDisplay': {},
+              'playProgressBar': {}
+            }
+          }
+        }
+      },
+
+      'p2PInfoButton': {},
+
+      'muteToggle': {},
+      'volumeControl': {},
+
+      'settingsButton': {
+        setup: {
+          maxHeightOffset: 40
+        },
+        entries: settingEntries
+      }
+    }
+
+    if (options.peertubeLink === true) {
+      Object.assign(children, {
+        'peerTubeLinkButton': {}
+      })
+    }
+
+    if (options.theaterMode === true) {
+      Object.assign(children, {
+        'theaterButton': {}
+      })
+    }
+
+    Object.assign(children, {
+      'fullscreenToggle': {}
+    })
+
+    return children
+  }
+
+  private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) {
+    const content = [
+      {
+        label: player.localize('Copy the video URL'),
+        listener: function () {
+          copyToClipboard(buildVideoLink())
+        }
+      },
+      {
+        label: player.localize('Copy the video URL at the current time'),
+        listener: function () {
+          const player = this as videojs.Player
+          copyToClipboard(buildVideoLink(player.currentTime()))
+        }
+      },
+      {
+        label: player.localize('Copy embed code'),
+        listener: () => {
+          copyToClipboard(buildVideoEmbed(videoEmbedUrl))
+        }
+      }
+    ]
+
+    if (mode === 'webtorrent') {
+      content.push({
+        label: player.localize('Copy magnet URI'),
+        listener: function () {
+          const player = this as videojs.Player
+          copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri)
+        }
+      })
+    }
+
+    player.contextmenuUI({ content })
+  }
+
+  private static getLocalePath (serverUrl: string, locale: string) {
+    const completeLocale = getCompleteLocale(locale)
+
+    if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
+
+    return serverUrl + '/client/locales/' + completeLocale
+  }
+}
+
+// ############################################################################
+
+export {
+  videojs
+}
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
deleted file mode 100644 (file)
index 2de6d7f..0000000
+++ /dev/null
@@ -1,300 +0,0 @@
-import { VideoFile } from '../../../../shared/models/videos'
-
-import 'videojs-hotkeys'
-import 'videojs-dock'
-import 'videojs-contextmenu-ui'
-import './peertube-link-button'
-import './resolution-menu-button'
-import './settings-menu-button'
-import './webtorrent-info-button'
-import './peertube-videojs-plugin'
-import './peertube-load-progress-bar'
-import './theater-button'
-import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
-import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
-import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
-
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
-videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
-// Change Captions to Subtitles/CC
-videojsUntyped.getComponent('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)
-videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
-
-function getVideojsOptions (options: {
-  autoplay: boolean
-  playerElement: HTMLVideoElement
-  videoViewUrl: string
-  videoDuration: number
-  videoFiles: VideoFile[]
-  enableHotkeys: boolean
-  inactivityTimeout: number
-  peertubeLink: boolean
-  poster: string
-  startTime: number | string
-  theaterMode: boolean
-  videoCaptions: VideoJSCaption[]
-
-  language?: string
-  controls?: boolean
-  muted?: boolean
-  loop?: boolean
-  subtitle?: string
-
-  userWatching?: UserWatching
-}) {
-  const videojsOptions = {
-    // We don't use text track settings for now
-    textTrackSettings: false,
-    controls: options.controls !== undefined ? options.controls : true,
-    loop: options.loop !== undefined ? options.loop : false,
-
-    muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage
-
-    poster: options.poster,
-    autoplay: false,
-    inactivityTimeout: options.inactivityTimeout,
-    playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
-    plugins: {
-      peertube: {
-        autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
-        videoCaptions: options.videoCaptions,
-        videoFiles: options.videoFiles,
-        playerElement: options.playerElement,
-        videoViewUrl: options.videoViewUrl,
-        videoDuration: options.videoDuration,
-        startTime: options.startTime,
-        userWatching: options.userWatching,
-        subtitle: options.subtitle
-      }
-    },
-    controlBar: {
-      children: getControlBarChildren(options)
-    }
-  }
-
-  if (options.enableHotkeys === true) {
-    Object.assign(videojsOptions.plugins, {
-      hotkeys: {
-        enableVolumeScroll: false,
-        enableModifiersForNumbers: false,
-
-        fullscreenKey: function (event: KeyboardEvent) {
-          // fullscreen with the f key or Ctrl+Enter
-          return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
-        },
-
-        seekStep: function (event: KeyboardEvent) {
-          // mimic VLC seek behavior, and default to 5 (original value is 5).
-          if (event.ctrlKey && event.altKey) {
-            return 5 * 60
-          } else if (event.ctrlKey) {
-            return 60
-          } else if (event.altKey) {
-            return 10
-          } else {
-            return 5
-          }
-        },
-
-        customKeys: {
-          increasePlaybackRateKey: {
-            key: function (event: KeyboardEvent) {
-              return event.key === '>'
-            },
-            handler: function (player: Player) {
-              player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
-            }
-          },
-          decreasePlaybackRateKey: {
-            key: function (event: KeyboardEvent) {
-              return event.key === '<'
-            },
-            handler: function (player: Player) {
-              player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
-            }
-          },
-          frameByFrame: {
-            key: function (event: KeyboardEvent) {
-              return event.key === '.'
-            },
-            handler: function (player: Player) {
-              player.pause()
-              // Calculate movement distance (assuming 30 fps)
-              const dist = 1 / 30
-              player.currentTime(player.currentTime() + dist)
-            }
-          }
-        }
-      }
-    })
-  }
-
-  if (options.language && !isDefaultLocale(options.language)) {
-    Object.assign(videojsOptions, { language: options.language })
-  }
-
-  return videojsOptions
-}
-
-function getControlBarChildren (options: {
-  peertubeLink: boolean
-  theaterMode: boolean,
-  videoCaptions: VideoJSCaption[]
-}) {
-  const settingEntries = []
-
-  // Keep an order
-  settingEntries.push('playbackRateMenuButton')
-  if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton')
-  settingEntries.push('resolutionMenuButton')
-
-  const children = {
-    'playToggle': {},
-    'currentTimeDisplay': {},
-    'timeDivider': {},
-    'durationDisplay': {},
-    'liveDisplay': {},
-
-    'flexibleWidthSpacer': {},
-    'progressControl': {
-      children: {
-        'seekBar': {
-          children: {
-            'peerTubeLoadProgressBar': {},
-            'mouseTimeDisplay': {},
-            'playProgressBar': {}
-          }
-        }
-      }
-    },
-
-    'webTorrentButton': {},
-
-    'muteToggle': {},
-    'volumeControl': {},
-
-    'settingsButton': {
-      setup: {
-        maxHeightOffset: 40
-      },
-      entries: settingEntries
-    }
-  }
-
-  if (options.peertubeLink === true) {
-    Object.assign(children, {
-      'peerTubeLinkButton': {}
-    })
-  }
-
-  if (options.theaterMode === true) {
-    Object.assign(children, {
-      'theaterButton': {}
-    })
-  }
-
-  Object.assign(children, {
-    'fullscreenToggle': {}
-  })
-
-  return children
-}
-
-function addContextMenu (player: any, videoEmbedUrl: string) {
-  player.contextmenuUI({
-    content: [
-      {
-        label: player.localize('Copy the video URL'),
-        listener: function () {
-          copyToClipboard(buildVideoLink())
-        }
-      },
-      {
-        label: player.localize('Copy the video URL at the current time'),
-        listener: function () {
-          const player = this as Player
-          copyToClipboard(buildVideoLink(player.currentTime()))
-        }
-      },
-      {
-        label: player.localize('Copy embed code'),
-        listener: () => {
-          copyToClipboard(buildVideoEmbed(videoEmbedUrl))
-        }
-      },
-      {
-        label: player.localize('Copy magnet URI'),
-        listener: function () {
-          const player = this as Player
-          copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri)
-        }
-      }
-    ]
-  })
-}
-
-function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) {
-  const path = getLocalePath(serverUrl, locale)
-  // It is the default locale, nothing to translate
-  if (!path) return Promise.resolve(undefined)
-
-  let p: Promise<any>
-
-  if (loadLocaleInVideoJS.cache[path]) {
-    p = Promise.resolve(loadLocaleInVideoJS.cache[path])
-  } else {
-    p = fetch(path + '/player.json')
-      .then(res => res.json())
-      .then(json => {
-        loadLocaleInVideoJS.cache[path] = json
-        return json
-      })
-      .catch(err => {
-        console.error('Cannot get player translations', err)
-        return undefined
-      })
-  }
-
-  const completeLocale = getCompleteLocale(locale)
-  return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
-}
-namespace loadLocaleInVideoJS {
-  export const cache: { [ path: string ]: any } = {}
-}
-
-function getServerTranslations (serverUrl: string, locale: string) {
-  const path = getLocalePath(serverUrl, locale)
-  // It is the default locale, nothing to translate
-  if (!path) return Promise.resolve(undefined)
-
-  return fetch(path + '/server.json')
-    .then(res => res.json())
-    .catch(err => {
-      console.error('Cannot get server translations', err)
-      return undefined
-    })
-}
-
-// ############################################################################
-
-export {
-  getServerTranslations,
-  loadLocaleInVideoJS,
-  getVideojsOptions,
-  addContextMenu
-}
-
-// ############################################################################
-
-function getLocalePath (serverUrl: string, locale: string) {
-  const completeLocale = getCompleteLocale(locale)
-
-  if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
-
-  return serverUrl + '/client/locales/' + completeLocale
-}
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts
new file mode 100644 (file)
index 0000000..0bd6076
--- /dev/null
@@ -0,0 +1,219 @@
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+import './videojs-components/settings-menu-button'
+import { PeerTubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
+import { isMobile, timeToInt } from './utils'
+import {
+  getStoredLastSubtitle,
+  getStoredMute,
+  getStoredVolume,
+  saveLastSubtitle,
+  saveMuteInStore,
+  saveVolumeInStore
+} from './peertube-player-local-storage'
+
+const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
+class PeerTubePlugin extends Plugin {
+  private readonly autoplay: boolean = false
+  private readonly startTime: number = 0
+  private readonly videoViewUrl: string
+  private readonly videoDuration: number
+  private readonly CONSTANTS = {
+    USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
+  }
+
+  private player: any
+  private videoCaptions: VideoJSCaption[]
+  private defaultSubtitle: string
+
+  private videoViewInterval: any
+  private userWatchingVideoInterval: any
+  private qualityObservationTimer: any
+
+  constructor (player: videojs.Player, options: PeerTubePluginOptions) {
+    super(player, options)
+
+    this.startTime = timeToInt(options.startTime)
+    this.videoViewUrl = options.videoViewUrl
+    this.videoDuration = options.videoDuration
+    this.videoCaptions = options.videoCaptions
+
+    if (this.autoplay === true) this.player.addClass('vjs-has-autoplay')
+
+    this.player.ready(() => {
+      const playerOptions = this.player.options_
+
+      const volume = getStoredVolume()
+      if (volume !== undefined) this.player.volume(volume)
+
+      const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
+      if (muted !== undefined) this.player.muted(muted)
+
+      this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
+
+      this.player.on('volumechange', () => {
+        saveVolumeInStore(this.player.volume())
+        saveMuteInStore(this.player.muted())
+      })
+
+      this.player.textTracks().on('change', () => {
+        const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
+          return t.kind === 'captions' && t.mode === 'showing'
+        })
+
+        if (!showing) {
+          saveLastSubtitle('off')
+          return
+        }
+
+        saveLastSubtitle(showing.language)
+      })
+
+      this.player.on('sourcechange', () => this.initCaptions())
+
+      this.player.duration(options.videoDuration)
+
+      this.initializePlayer()
+      this.runViewAdd()
+
+      if (options.userWatching) this.runUserWatchVideo(options.userWatching)
+    })
+  }
+
+  dispose () {
+    clearTimeout(this.qualityObservationTimer)
+
+    clearInterval(this.videoViewInterval)
+
+    if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
+  }
+
+  private initializePlayer () {
+    if (isMobile()) this.player.addClass('vjs-is-mobile')
+
+    this.initSmoothProgressBar()
+
+    this.initCaptions()
+
+    this.alterInactivity()
+  }
+
+  private runViewAdd () {
+    this.clearVideoViewInterval()
+
+    // After 30 seconds (or 3/4 of the video), add a view to the video
+    let minSecondsToView = 30
+
+    if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4
+
+    let secondsViewed = 0
+    this.videoViewInterval = setInterval(() => {
+      if (this.player && !this.player.paused()) {
+        secondsViewed += 1
+
+        if (secondsViewed > minSecondsToView) {
+          this.clearVideoViewInterval()
+
+          this.addViewToVideo().catch(err => console.error(err))
+        }
+      }
+    }, 1000)
+  }
+
+  private runUserWatchVideo (options: UserWatching) {
+    let lastCurrentTime = 0
+
+    this.userWatchingVideoInterval = setInterval(() => {
+      const currentTime = Math.floor(this.player.currentTime())
+
+      if (currentTime - lastCurrentTime >= 1) {
+        lastCurrentTime = currentTime
+
+        this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
+          .catch(err => console.error('Cannot notify user is watching.', err))
+      }
+    }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
+  }
+
+  private clearVideoViewInterval () {
+    if (this.videoViewInterval !== undefined) {
+      clearInterval(this.videoViewInterval)
+      this.videoViewInterval = undefined
+    }
+  }
+
+  private addViewToVideo () {
+    if (!this.videoViewUrl) return Promise.resolve(undefined)
+
+    return fetch(this.videoViewUrl, { method: 'POST' })
+  }
+
+  private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
+    const body = new URLSearchParams()
+    body.append('currentTime', currentTime.toString())
+
+    const headers = new Headers({ 'Authorization': authorizationHeader })
+
+    return fetch(url, { method: 'PUT', body, headers })
+  }
+
+  private alterInactivity () {
+    let saveInactivityTimeout: number
+
+    const disableInactivity = () => {
+      saveInactivityTimeout = this.player.options_.inactivityTimeout
+      this.player.options_.inactivityTimeout = 0
+    }
+    const enableInactivity = () => {
+      this.player.options_.inactivityTimeout = saveInactivityTimeout
+    }
+
+    const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog')
+
+    this.player.controlBar.on('mouseenter', () => disableInactivity())
+    settingsDialog.on('mouseenter', () => disableInactivity())
+    this.player.controlBar.on('mouseleave', () => enableInactivity())
+    settingsDialog.on('mouseleave', () => enableInactivity())
+  }
+
+  private initCaptions () {
+    for (const caption of this.videoCaptions) {
+      this.player.addRemoteTextTrack({
+        kind: 'captions',
+        label: caption.label,
+        language: caption.language,
+        id: caption.language,
+        src: caption.src,
+        default: this.defaultSubtitle === caption.language
+      }, false)
+    }
+
+    this.player.trigger('captionsChanged')
+  }
+
+  // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
+  private initSmoothProgressBar () {
+    const SeekBar = videojsUntyped.getComponent('SeekBar')
+    SeekBar.prototype.getPercent = function getPercent () {
+      // Allows for smooth scrubbing, when player can't keep up.
+      // const time = (this.player_.scrubbing()) ?
+      //   this.player_.getCache().currentTime :
+      //   this.player_.currentTime()
+      const time = this.player_.currentTime()
+      const percent = time / this.player_.duration()
+      return percent >= 1 ? 1 : percent
+    }
+    SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
+      let newTime = this.calculateDistance(event) * this.player_.duration()
+      if (newTime === this.player_.duration()) {
+        newTime = newTime - 0.1
+      }
+      this.player_.currentTime(newTime)
+      this.update()
+    }
+  }
+}
+
+videojs.registerPlugin('peertube', PeerTubePlugin)
+export { PeerTubePlugin }
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts
deleted file mode 100644 (file)
index e9fb90c..0000000
+++ /dev/null
@@ -1,754 +0,0 @@
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as videojs from 'video.js'
-
-import * as WebTorrent from 'webtorrent'
-import { VideoFile } from '../../../../shared/models/videos/video.model'
-import { renderVideo } from './video-renderer'
-import './settings-menu-button'
-import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
-import { PeertubeChunkStore } from './peertube-chunk-store'
-import {
-  getAverageBandwidthInStore,
-  getStoredLastSubtitle,
-  getStoredMute,
-  getStoredVolume,
-  getStoredWebTorrentEnabled,
-  saveAverageBandwidth,
-  saveLastSubtitle,
-  saveMuteInStore,
-  saveVolumeInStore
-} from './peertube-player-local-storage'
-
-const CacheChunkStore = require('cache-chunk-store')
-
-type PlayOptions = {
-  forcePlay?: boolean,
-  seek?: number,
-  delay?: number
-}
-
-const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
-class PeerTubePlugin extends Plugin {
-  private readonly playerElement: HTMLVideoElement
-
-  private readonly autoplay: boolean = false
-  private readonly startTime: number = 0
-  private readonly savePlayerSrcFunction: Function
-  private readonly videoFiles: VideoFile[]
-  private readonly videoViewUrl: string
-  private readonly videoDuration: number
-  private readonly CONSTANTS = {
-    INFO_SCHEDULER: 1000, // Don't change this
-    AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
-    AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
-    AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
-    AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
-    BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth
-    USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
-  }
-
-  private readonly webtorrent = new WebTorrent({
-    tracker: {
-      rtcConfig: {
-        iceServers: [
-          {
-            urls: 'stun:stun.stunprotocol.org'
-          },
-          {
-            urls: 'stun:stun.framasoft.org'
-          }
-        ]
-      }
-    },
-    dht: false
-  })
-
-  private player: any
-  private currentVideoFile: VideoFile
-  private torrent: WebTorrent.Torrent
-  private videoCaptions: VideoJSCaption[]
-  private defaultSubtitle: string
-
-  private renderer: any
-  private fakeRenderer: any
-  private destroyingFakeRenderer = false
-
-  private autoResolution = true
-  private forbidAutoResolution = false
-  private isAutoResolutionObservation = false
-  private playerRefusedP2P = false
-
-  private videoViewInterval: any
-  private torrentInfoInterval: any
-  private autoQualityInterval: any
-  private userWatchingVideoInterval: any
-  private addTorrentDelay: any
-  private qualityObservationTimer: any
-  private runAutoQualitySchedulerTimer: any
-
-  private downloadSpeeds: number[] = []
-
-  constructor (player: videojs.Player, options: PeertubePluginOptions) {
-    super(player, options)
-
-    // Disable auto play on iOS
-    this.autoplay = options.autoplay && this.isIOS() === false
-    this.playerRefusedP2P = !getStoredWebTorrentEnabled()
-
-    this.startTime = timeToInt(options.startTime)
-    this.videoFiles = options.videoFiles
-    this.videoViewUrl = options.videoViewUrl
-    this.videoDuration = options.videoDuration
-    this.videoCaptions = options.videoCaptions
-
-    this.savePlayerSrcFunction = this.player.src
-    this.playerElement = options.playerElement
-
-    if (this.autoplay === true) this.player.addClass('vjs-has-autoplay')
-
-    this.player.ready(() => {
-      const playerOptions = this.player.options_
-
-      const volume = getStoredVolume()
-      if (volume !== undefined) this.player.volume(volume)
-
-      const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
-      if (muted !== undefined) this.player.muted(muted)
-
-      this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
-
-      this.player.on('volumechange', () => {
-        saveVolumeInStore(this.player.volume())
-        saveMuteInStore(this.player.muted())
-      })
-
-      this.player.textTracks().on('change', () => {
-        const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
-          return t.kind === 'captions' && t.mode === 'showing'
-        })
-
-        if (!showing) {
-          saveLastSubtitle('off')
-          return
-        }
-
-        saveLastSubtitle(showing.language)
-      })
-
-      this.player.duration(options.videoDuration)
-
-      this.initializePlayer()
-      this.runTorrentInfoScheduler()
-      this.runViewAdd()
-
-      if (options.userWatching) this.runUserWatchVideo(options.userWatching)
-
-      this.player.one('play', () => {
-        // Don't run immediately scheduler, wait some seconds the TCP connections are made
-        this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
-      })
-    })
-  }
-
-  dispose () {
-    clearTimeout(this.addTorrentDelay)
-    clearTimeout(this.qualityObservationTimer)
-    clearTimeout(this.runAutoQualitySchedulerTimer)
-
-    clearInterval(this.videoViewInterval)
-    clearInterval(this.torrentInfoInterval)
-    clearInterval(this.autoQualityInterval)
-
-    if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
-
-    // Don't need to destroy renderer, video player will be destroyed
-    this.flushVideoFile(this.currentVideoFile, false)
-
-    this.destroyFakeRenderer()
-  }
-
-  getCurrentResolutionId () {
-    return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
-  }
-
-  getCurrentResolutionLabel () {
-    if (!this.currentVideoFile) return ''
-
-    const fps = this.currentVideoFile.fps >= 50 ? this.currentVideoFile.fps : ''
-    return this.currentVideoFile.resolution.label + fps
-  }
-
-  updateVideoFile (
-    videoFile?: VideoFile,
-    options: {
-      forcePlay?: boolean,
-      seek?: number,
-      delay?: number
-    } = {},
-    done: () => void = () => { /* empty */ }
-  ) {
-    // Automatically choose the adapted video file
-    if (videoFile === undefined) {
-      const savedAverageBandwidth = getAverageBandwidthInStore()
-      videoFile = savedAverageBandwidth
-        ? this.getAppropriateFile(savedAverageBandwidth)
-        : this.pickAverageVideoFile()
-    }
-
-    // Don't add the same video file once again
-    if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
-      return
-    }
-
-    // Do not display error to user because we will have multiple fallback
-    this.disableErrorDisplay()
-
-    // Hack to "simulate" src link in video.js >= 6
-    // Without this, we can't play the video after pausing it
-    // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
-    this.player.src = () => true
-    const oldPlaybackRate = this.player.playbackRate()
-
-    const previousVideoFile = this.currentVideoFile
-    this.currentVideoFile = videoFile
-
-    // Don't try on iOS that does not support MediaSource
-    // Or don't use P2P if webtorrent is disabled
-    if (this.isIOS() || this.playerRefusedP2P) {
-      return this.fallbackToHttp(options, () => {
-        this.player.playbackRate(oldPlaybackRate)
-        return done()
-      })
-    }
-
-    this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
-      this.player.playbackRate(oldPlaybackRate)
-      return done()
-    })
-
-    this.trigger('videoFileUpdate')
-  }
-
-  updateResolution (resolutionId: number, delay = 0) {
-    // Remember player state
-    const currentTime = this.player.currentTime()
-    const isPaused = this.player.paused()
-
-    // Remove poster to have black background
-    this.playerElement.poster = ''
-
-    // Hide bigPlayButton
-    if (!isPaused) {
-      this.player.bigPlayButton.hide()
-    }
-
-    const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
-    const options = {
-      forcePlay: false,
-      delay,
-      seek: currentTime + (delay / 1000)
-    }
-    this.updateVideoFile(newVideoFile, options)
-  }
-
-  flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
-    if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
-      if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
-
-      this.webtorrent.remove(videoFile.magnetUri)
-      console.log('Removed ' + videoFile.magnetUri)
-    }
-  }
-
-  isAutoResolutionOn () {
-    return this.autoResolution
-  }
-
-  enableAutoResolution () {
-    this.autoResolution = true
-    this.trigger('autoResolutionUpdate')
-  }
-
-  disableAutoResolution (forbid = false) {
-    if (forbid === true) this.forbidAutoResolution = true
-
-    this.autoResolution = false
-    this.trigger('autoResolutionUpdate')
-  }
-
-  isAutoResolutionForbidden () {
-    return this.forbidAutoResolution === true
-  }
-
-  getCurrentVideoFile () {
-    return this.currentVideoFile
-  }
-
-  getTorrent () {
-    return this.torrent
-  }
-
-  private addTorrent (
-    magnetOrTorrentUrl: string,
-    previousVideoFile: VideoFile,
-    options: PlayOptions,
-    done: Function
-  ) {
-    console.log('Adding ' + magnetOrTorrentUrl + '.')
-
-    const oldTorrent = this.torrent
-    const torrentOptions = {
-      store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
-        max: 100
-      })
-    }
-
-    this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
-      console.log('Added ' + magnetOrTorrentUrl + '.')
-
-      if (oldTorrent) {
-        // Pause the old torrent
-        this.stopTorrent(oldTorrent)
-
-        // We use a fake renderer so we download correct pieces of the next file
-        if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
-      }
-
-      // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
-      this.addTorrentDelay = setTimeout(() => {
-        // We don't need the fake renderer anymore
-        this.destroyFakeRenderer()
-
-        const paused = this.player.paused()
-
-        this.flushVideoFile(previousVideoFile)
-
-        // Update progress bar (just for the UI), do not wait rendering
-        if (options.seek) this.player.currentTime(options.seek)
-
-        const renderVideoOptions = { autoplay: false, controls: true }
-        renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
-          this.renderer = renderer
-
-          if (err) return this.fallbackToHttp(options, done)
-
-          return this.tryToPlay(err => {
-            if (err) return done(err)
-
-            if (options.seek) this.seek(options.seek)
-            if (options.forcePlay === false && paused === true) this.player.pause()
-
-            return done()
-          })
-        })
-      }, options.delay || 0)
-    })
-
-    this.torrent.on('error', (err: any) => console.error(err))
-
-    this.torrent.on('warning', (err: any) => {
-      // We don't support HTTP tracker but we don't care -> we use the web socket tracker
-      if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
-
-      // Users don't care about issues with WebRTC, but developers do so log it in the console
-      if (err.message.indexOf('Ice connection failed') !== -1) {
-        console.log(err)
-        return
-      }
-
-      // Magnet hash is not up to date with the torrent file, add directly the torrent file
-      if (err.message.indexOf('incorrect info hash') !== -1) {
-        console.error('Incorrect info hash detected, falling back to torrent file.')
-        const newOptions = { forcePlay: true, seek: options.seek }
-        return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
-      }
-
-      // Remote instance is down
-      if (err.message.indexOf('from xs param') !== -1) {
-        this.handleError(err)
-      }
-
-      console.warn(err)
-    })
-  }
-
-  private tryToPlay (done?: (err?: Error) => void) {
-    if (!done) done = function () { /* empty */ }
-
-    const playPromise = this.player.play()
-    if (playPromise !== undefined) {
-      return playPromise.then(done)
-                        .catch((err: Error) => {
-                          if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
-                            return
-                          }
-
-                          console.error(err)
-                          this.player.pause()
-                          this.player.posterImage.show()
-                          this.player.removeClass('vjs-has-autoplay')
-                          this.player.removeClass('vjs-has-big-play-button-clicked')
-
-                          return done()
-                        })
-    }
-
-    return done()
-  }
-
-  private seek (time: number) {
-    this.player.currentTime(time)
-    this.player.handleTechSeeked_()
-  }
-
-  private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
-    if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined
-    if (this.videoFiles.length === 1) return this.videoFiles[0]
-
-    // Don't change the torrent is the play was ended
-    if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
-
-    if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
-
-    // Limit resolution according to player height
-    const playerHeight = this.playerElement.offsetHeight as number
-
-    // We take the first resolution just above the player height
-    // Example: player height is 530px, we want the 720p file instead of 480p
-    let maxResolution = this.videoFiles[0].resolution.id
-    for (let i = this.videoFiles.length - 1; i >= 0; i--) {
-      const resolutionId = this.videoFiles[i].resolution.id
-      if (resolutionId >= playerHeight) {
-        maxResolution = resolutionId
-        break
-      }
-    }
-
-    // Filter videos we can play according to our screen resolution and bandwidth
-    const filteredFiles = this.videoFiles
-                              .filter(f => f.resolution.id <= maxResolution)
-                              .filter(f => {
-                                const fileBitrate = (f.size / this.videoDuration)
-                                let threshold = fileBitrate
-
-                                // If this is for a higher resolution or an initial load: add a margin
-                                if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
-                                  threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
-                                }
-
-                                return averageDownloadSpeed > threshold
-                              })
-
-    // If the download speed is too bad, return the lowest resolution we have
-    if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles)
-
-    return videoFileMaxByResolution(filteredFiles)
-  }
-
-  private getAndSaveActualDownloadSpeed () {
-    const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
-    const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
-    if (lastDownloadSpeeds.length === 0) return -1
-
-    const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
-    const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
-
-    // Save the average bandwidth for future use
-    saveAverageBandwidth(averageBandwidth)
-
-    return averageBandwidth
-  }
-
-  private initializePlayer () {
-    if (isMobile()) this.player.addClass('vjs-is-mobile')
-
-    this.initSmoothProgressBar()
-
-    this.initCaptions()
-
-    this.alterInactivity()
-
-    if (this.autoplay === true) {
-      this.player.posterImage.hide()
-
-      return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
-    }
-
-    // Proxy first play
-    const oldPlay = this.player.play.bind(this.player)
-    this.player.play = () => {
-      this.player.addClass('vjs-has-big-play-button-clicked')
-      this.player.play = oldPlay
-
-      this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
-    }
-  }
-
-  private runAutoQualityScheduler () {
-    this.autoQualityInterval = setInterval(() => {
-
-      // Not initialized or in HTTP fallback
-      if (this.torrent === undefined || this.torrent === null) return
-      if (this.isAutoResolutionOn() === false) return
-      if (this.isAutoResolutionObservation === true) return
-
-      const file = this.getAppropriateFile()
-      let changeResolution = false
-      let changeResolutionDelay = 0
-
-      // Lower resolution
-      if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
-        console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
-        changeResolution = true
-      } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
-        console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
-        changeResolution = true
-        changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
-      }
-
-      if (changeResolution === true) {
-        this.updateResolution(file.resolution.id, changeResolutionDelay)
-
-        // Wait some seconds in observation of our new resolution
-        this.isAutoResolutionObservation = true
-
-        this.qualityObservationTimer = setTimeout(() => {
-          this.isAutoResolutionObservation = false
-        }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
-      }
-    }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
-  }
-
-  private isPlayerWaiting () {
-    return this.player && this.player.hasClass('vjs-waiting')
-  }
-
-  private runTorrentInfoScheduler () {
-    this.torrentInfoInterval = setInterval(() => {
-      // Not initialized yet
-      if (this.torrent === undefined) return
-
-      // Http fallback
-      if (this.torrent === null) return this.trigger('torrentInfo', false)
-
-      // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
-      if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
-
-      return this.trigger('torrentInfo', {
-        downloadSpeed: this.torrent.downloadSpeed,
-        numPeers: this.torrent.numPeers,
-        uploadSpeed: this.torrent.uploadSpeed,
-        downloaded: this.torrent.downloaded,
-        uploaded: this.torrent.uploaded
-      })
-    }, this.CONSTANTS.INFO_SCHEDULER)
-  }
-
-  private runViewAdd () {
-    this.clearVideoViewInterval()
-
-    // After 30 seconds (or 3/4 of the video), add a view to the video
-    let minSecondsToView = 30
-
-    if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4
-
-    let secondsViewed = 0
-    this.videoViewInterval = setInterval(() => {
-      if (this.player && !this.player.paused()) {
-        secondsViewed += 1
-
-        if (secondsViewed > minSecondsToView) {
-          this.clearVideoViewInterval()
-
-          this.addViewToVideo().catch(err => console.error(err))
-        }
-      }
-    }, 1000)
-  }
-
-  private runUserWatchVideo (options: UserWatching) {
-    let lastCurrentTime = 0
-
-    this.userWatchingVideoInterval = setInterval(() => {
-      const currentTime = Math.floor(this.player.currentTime())
-
-      if (currentTime - lastCurrentTime >= 1) {
-        lastCurrentTime = currentTime
-
-        this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
-          .catch(err => console.error('Cannot notify user is watching.', err))
-      }
-    }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
-  }
-
-  private clearVideoViewInterval () {
-    if (this.videoViewInterval !== undefined) {
-      clearInterval(this.videoViewInterval)
-      this.videoViewInterval = undefined
-    }
-  }
-
-  private addViewToVideo () {
-    if (!this.videoViewUrl) return Promise.resolve(undefined)
-
-    return fetch(this.videoViewUrl, { method: 'POST' })
-  }
-
-  private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
-    const body = new URLSearchParams()
-    body.append('currentTime', currentTime.toString())
-
-    const headers = new Headers({ 'Authorization': authorizationHeader })
-
-    return fetch(url, { method: 'PUT', body, headers })
-  }
-
-  private fallbackToHttp (options: PlayOptions, done?: Function) {
-    const paused = this.player.paused()
-
-    this.disableAutoResolution(true)
-
-    this.flushVideoFile(this.currentVideoFile, true)
-    this.torrent = null
-
-    // Enable error display now this is our last fallback
-    this.player.one('error', () => this.enableErrorDisplay())
-
-    const httpUrl = this.currentVideoFile.fileUrl
-    this.player.src = this.savePlayerSrcFunction
-    this.player.src(httpUrl)
-
-    // We changed the source, so reinit captions
-    this.initCaptions()
-
-    return this.tryToPlay(err => {
-      if (err && done) return done(err)
-
-      if (options.seek) this.seek(options.seek)
-      if (options.forcePlay === false && paused === true) this.player.pause()
-
-      if (done) return done()
-    })
-  }
-
-  private handleError (err: Error | string) {
-    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 isIOS () {
-    return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
-  }
-
-  private alterInactivity () {
-    let saveInactivityTimeout: number
-
-    const disableInactivity = () => {
-      saveInactivityTimeout = this.player.options_.inactivityTimeout
-      this.player.options_.inactivityTimeout = 0
-    }
-    const enableInactivity = () => {
-      this.player.options_.inactivityTimeout = saveInactivityTimeout
-    }
-
-    const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog')
-
-    this.player.controlBar.on('mouseenter', () => disableInactivity())
-    settingsDialog.on('mouseenter', () => disableInactivity())
-    this.player.controlBar.on('mouseleave', () => enableInactivity())
-    settingsDialog.on('mouseleave', () => enableInactivity())
-  }
-
-  private pickAverageVideoFile () {
-    if (this.videoFiles.length === 1) return this.videoFiles[0]
-
-    return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
-  }
-
-  private stopTorrent (torrent: WebTorrent.Torrent) {
-    torrent.pause()
-    // Pause does not remove actual peers (in particular the webseed peer)
-    torrent.removePeer(torrent[ 'ws' ])
-  }
-
-  private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
-    this.destroyingFakeRenderer = false
-
-    const fakeVideoElem = document.createElement('video')
-    renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
-      this.fakeRenderer = renderer
-
-      // The renderer returns an error when we destroy it, so skip them
-      if (this.destroyingFakeRenderer === false && err) {
-        console.error('Cannot render new torrent in fake video element.', err)
-      }
-
-      // Load the future file at the correct time (in delay MS - 2 seconds)
-      fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
-    })
-  }
-
-  private destroyFakeRenderer () {
-    if (this.fakeRenderer) {
-      this.destroyingFakeRenderer = true
-
-      if (this.fakeRenderer.destroy) {
-        try {
-          this.fakeRenderer.destroy()
-        } catch (err) {
-          console.log('Cannot destroy correctly fake renderer.', err)
-        }
-      }
-      this.fakeRenderer = undefined
-    }
-  }
-
-  private initCaptions () {
-    for (const caption of this.videoCaptions) {
-      this.player.addRemoteTextTrack({
-        kind: 'captions',
-        label: caption.label,
-        language: caption.language,
-        id: caption.language,
-        src: caption.src,
-        default: this.defaultSubtitle === caption.language
-      }, false)
-    }
-
-    this.player.trigger('captionsChanged')
-  }
-
-  // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
-  private initSmoothProgressBar () {
-    const SeekBar = videojsUntyped.getComponent('SeekBar')
-    SeekBar.prototype.getPercent = function getPercent () {
-      // Allows for smooth scrubbing, when player can't keep up.
-      // const time = (this.player_.scrubbing()) ?
-      //   this.player_.getCache().currentTime :
-      //   this.player_.currentTime()
-      const time = this.player_.currentTime()
-      const percent = time / this.player_.duration()
-      return percent >= 1 ? 1 : percent
-    }
-    SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
-      let newTime = this.calculateDistance(event) * this.player_.duration()
-      if (newTime === this.player_.duration()) {
-        newTime = newTime - 0.1
-      }
-      this.player_.currentTime(newTime)
-      this.update()
-    }
-  }
-}
-
-videojs.registerPlugin('peertube', PeerTubePlugin)
-export { PeerTubePlugin }
index 634c7fdc9df0e38484c31cebe5ed2b0b1b721c49..060ea4dce4d49f96de16ed4d4cb0794e19befc4e 100644 (file)
@@ -3,11 +3,13 @@
 import * as videojs from 'video.js'
 
 import { VideoFile } from '../../../../shared/models/videos/video.model'
-import { PeerTubePlugin } from './peertube-videojs-plugin'
+import { PeerTubePlugin } from './peertube-plugin'
+import { WebTorrentPlugin } from './webtorrent-plugin'
 
 declare namespace videojs {
   interface Player {
     peertube (): PeerTubePlugin
+    webtorrent (): WebTorrentPlugin
   }
 }
 
@@ -30,26 +32,73 @@ type UserWatching = {
   authorizationHeader: string
 }
 
-type PeertubePluginOptions = {
-  videoFiles: VideoFile[]
-  playerElement: HTMLVideoElement
+type PeerTubePluginOptions = {
+  autoplay: boolean
   videoViewUrl: string
   videoDuration: number
   startTime: number | string
-  autoplay: boolean,
-  videoCaptions: VideoJSCaption[]
 
-  subtitle?: string
   userWatching?: UserWatching
+  subtitle?: string
+
+  videoCaptions: VideoJSCaption[]
+}
+
+type WebtorrentPluginOptions = {
+  playerElement: HTMLVideoElement
+
+  autoplay: boolean
+  videoDuration: number
+
+  videoFiles: VideoFile[]
+}
+
+type P2PMediaLoaderPluginOptions = {
+  type: string
+  src: string
+}
+
+type VideoJSPluginOptions = {
+  peertube: PeerTubePluginOptions
+
+  webtorrent?: WebtorrentPluginOptions
+
+  p2pMediaLoader?: P2PMediaLoaderPluginOptions
 }
 
 // videojs typings don't have some method we need
 const videojsUntyped = videojs as any
 
+type LoadedQualityData = {
+  qualitySwitchCallback: Function,
+  qualityData: {
+    video: {
+      id: number
+      label: string
+      selected: boolean
+    }[]
+  }
+}
+
+type ResolutionUpdateData = {
+  auto: boolean,
+  resolutionId: number
+}
+
+type AutoResolutionUpdateData = {
+  possible: boolean
+}
+
 export {
+  ResolutionUpdateData,
+  AutoResolutionUpdateData,
   VideoJSComponentInterface,
-  PeertubePluginOptions,
   videojsUntyped,
   VideoJSCaption,
-  UserWatching
+  UserWatching,
+  PeerTubePluginOptions,
+  WebtorrentPluginOptions,
+  P2PMediaLoaderPluginOptions,
+  VideoJSPluginOptions,
+  LoadedQualityData
 }
diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts
deleted file mode 100644 (file)
index a3c1108..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { ResolutionMenuItem } from './resolution-menu-item'
-
-const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
-const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton')
-class ResolutionMenuButton extends MenuButton {
-  label: HTMLElement
-
-  constructor (player: Player, options: any) {
-    super(player, options)
-    this.player = player
-
-    player.peertube().on('videoFileUpdate', () => this.updateLabel())
-    player.peertube().on('autoResolutionUpdate', () => this.updateLabel())
-  }
-
-  createEl () {
-    const el = super.createEl()
-
-    this.labelEl_ = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-resolution-value',
-      innerHTML: this.buildLabelHTML()
-    })
-
-    el.appendChild(this.labelEl_)
-
-    return el
-  }
-
-  updateARIAAttributes () {
-    this.el().setAttribute('aria-label', 'Quality')
-  }
-
-  createMenu () {
-    const menu = new Menu(this.player_)
-    for (const videoFile of this.player_.peertube().videoFiles) {
-      let label = videoFile.resolution.label
-      if (videoFile.fps && videoFile.fps >= 50) {
-        label += videoFile.fps
-      }
-
-      menu.addChild(new ResolutionMenuItem(
-        this.player_,
-        {
-          id: videoFile.resolution.id,
-          label,
-          src: videoFile.magnetUri
-        })
-      )
-    }
-
-    menu.addChild(new ResolutionMenuItem(
-      this.player_,
-      {
-        id: -1,
-        label: this.player_.localize('Auto'),
-        src: null
-      }
-    ))
-
-    return menu
-  }
-
-  updateLabel () {
-    if (!this.labelEl_) return
-
-    this.labelEl_.innerHTML = this.buildLabelHTML()
-  }
-
-  buildCSSClass () {
-    return super.buildCSSClass() + ' vjs-resolution-button'
-  }
-
-  buildWrapperCSSClass () {
-    return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
-  }
-
-  private buildLabelHTML () {
-    return this.player_.peertube().getCurrentResolutionLabel()
-  }
-}
-ResolutionMenuButton.prototype.controlText_ = 'Quality'
-
-MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts
deleted file mode 100644 (file)
index b54fd91..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-
-const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
-class ResolutionMenuItem extends MenuItem {
-
-  constructor (player: Player, options: any) {
-    const currentResolutionId = player.peertube().getCurrentResolutionId()
-    options.selectable = true
-    options.selected = options.id === currentResolutionId
-
-    super(player, options)
-
-    this.label = options.label
-    this.id = options.id
-
-    player.peertube().on('videoFileUpdate', () => this.updateSelection())
-    player.peertube().on('autoResolutionUpdate', () => this.updateSelection())
-  }
-
-  handleClick (event: any) {
-    if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return
-
-    super.handleClick(event)
-
-    // Auto resolution
-    if (this.id === -1) {
-      this.player_.peertube().enableAutoResolution()
-      return
-    }
-
-    this.player_.peertube().disableAutoResolution()
-    this.player_.peertube().updateResolution(this.id)
-  }
-
-  updateSelection () {
-    // Check if auto resolution is forbidden or not
-    if (this.id === -1) {
-      if (this.player_.peertube().isAutoResolutionForbidden()) {
-        this.addClass('disabled')
-      } else {
-        this.removeClass('disabled')
-      }
-    }
-
-    if (this.player_.peertube().isAutoResolutionOn()) {
-      this.selected(this.id === -1)
-      return
-    }
-
-    this.selected(this.player_.peertube().getCurrentResolutionId() === this.id)
-  }
-
-  getLabel () {
-    if (this.id === -1) {
-      return this.label + ' <small>' + this.player_.peertube().getCurrentResolutionLabel() + '</small>'
-    }
-
-    return this.label
-  }
-}
-MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
-
-export { ResolutionMenuItem }
diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts
deleted file mode 100644 (file)
index a7aefdc..0000000
+++ /dev/null
@@ -1,288 +0,0 @@
-// Author: Yanko Shterev
-// Thanks https://github.com/yshterev/videojs-settings-menu
-
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as videojs from 'video.js'
-
-import { SettingsMenuItem } from './settings-menu-item'
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { toTitleCase } from './utils'
-
-const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
-const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
-const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
-
-class SettingsButton extends Button {
-  constructor (player: videojs.Player, options: any) {
-    super(player, options)
-
-    this.playerComponent = player
-    this.dialog = this.playerComponent.addChild('settingsDialog')
-    this.dialogEl = this.dialog.el_
-    this.menu = null
-    this.panel = this.dialog.addChild('settingsPanel')
-    this.panelChild = this.panel.addChild('settingsPanelChild')
-
-    this.addClass('vjs-settings')
-    this.el_.setAttribute('aria-label', 'Settings Button')
-
-    // Event handlers
-    this.addSettingsItemHandler = this.onAddSettingsItem.bind(this)
-    this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this)
-    this.playerClickHandler = this.onPlayerClick.bind(this)
-    this.userInactiveHandler = this.onUserInactive.bind(this)
-
-    this.buildMenu()
-    this.bindEvents()
-
-    // Prepare the dialog
-    this.player().one('play', () => this.hideDialog())
-  }
-
-  onPlayerClick (event: MouseEvent) {
-    const element = event.target as HTMLElement
-    if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) {
-      return
-    }
-
-    if (!this.dialog.hasClass('vjs-hidden')) {
-      this.hideDialog()
-    }
-  }
-
-  onDisposeSettingsItem (event: any, name: string) {
-    if (name === undefined) {
-      let children = this.menu.children()
-
-      while (children.length > 0) {
-        children[0].dispose()
-        this.menu.removeChild(children[0])
-      }
-
-      this.addClass('vjs-hidden')
-    } else {
-      let item = this.menu.getChild(name)
-
-      if (item) {
-        item.dispose()
-        this.menu.removeChild(item)
-      }
-    }
-
-    this.hideDialog()
-
-    if (this.options_.entries.length === 0) {
-      this.addClass('vjs-hidden')
-    }
-  }
-
-  onAddSettingsItem (event: any, data: any) {
-    const [ entry, options ] = data
-
-    this.addMenuItem(entry, options)
-    this.removeClass('vjs-hidden')
-  }
-
-  onUserInactive () {
-    if (!this.dialog.hasClass('vjs-hidden')) {
-      this.hideDialog()
-    }
-  }
-
-  bindEvents () {
-    this.playerComponent.on('click', this.playerClickHandler)
-    this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler)
-    this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler)
-    this.playerComponent.on('userinactive', this.userInactiveHandler)
-  }
-
-  buildCSSClass () {
-    return `vjs-icon-settings ${super.buildCSSClass()}`
-  }
-
-  handleClick () {
-    if (this.dialog.hasClass('vjs-hidden')) {
-      this.showDialog()
-    } else {
-      this.hideDialog()
-    }
-  }
-
-  showDialog () {
-    this.menu.el_.style.opacity = '1'
-    this.dialog.show()
-
-    this.setDialogSize(this.getComponentSize(this.menu))
-  }
-
-  hideDialog () {
-    this.dialog.hide()
-    this.setDialogSize(this.getComponentSize(this.menu))
-    this.menu.el_.style.opacity = '1'
-    this.resetChildren()
-  }
-
-  getComponentSize (element: any) {
-    let width: number = null
-    let height: number = null
-
-    // Could be component or just DOM element
-    if (element instanceof Component) {
-      width = element.el_.offsetWidth
-      height = element.el_.offsetHeight
-
-      // keep width/height as properties for direct use
-      element.width = width
-      element.height = height
-    } else {
-      width = element.offsetWidth
-      height = element.offsetHeight
-    }
-
-    return [ width, height ]
-  }
-
-  setDialogSize ([ width, height ]: number[]) {
-    if (typeof height !== 'number') {
-      return
-    }
-
-    let offset = this.options_.setup.maxHeightOffset
-    let maxHeight = this.playerComponent.el_.offsetHeight - offset
-
-    if (height > maxHeight) {
-      height = maxHeight
-      width += 17
-      this.panel.el_.style.maxHeight = `${height}px`
-    } else if (this.panel.el_.style.maxHeight !== '') {
-      this.panel.el_.style.maxHeight = ''
-    }
-
-    this.dialogEl.style.width = `${width}px`
-    this.dialogEl.style.height = `${height}px`
-  }
-
-  buildMenu () {
-    this.menu = new Menu(this.player())
-    this.menu.addClass('vjs-main-menu')
-    let entries = this.options_.entries
-
-    if (entries.length === 0) {
-      this.addClass('vjs-hidden')
-      this.panelChild.addChild(this.menu)
-      return
-    }
-
-    for (let entry of entries) {
-      this.addMenuItem(entry, this.options_)
-    }
-
-    this.panelChild.addChild(this.menu)
-  }
-
-  addMenuItem (entry: any, options: any) {
-    const openSubMenu = function (this: any) {
-      if (videojsUntyped.dom.hasClass(this.el_, 'open')) {
-        videojsUntyped.dom.removeClass(this.el_, 'open')
-      } else {
-        videojsUntyped.dom.addClass(this.el_, 'open')
-      }
-    }
-
-    options.name = toTitleCase(entry)
-    let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any)
-
-    this.menu.addChild(settingsMenuItem)
-
-    // Hide children to avoid sub menus stacking on top of each other
-    // or having multiple menus open
-    settingsMenuItem.on('click', videojs.bind(this, this.hideChildren))
-
-    // Whether to add or remove selected class on the settings sub menu element
-    settingsMenuItem.on('click', openSubMenu)
-  }
-
-  resetChildren () {
-    for (let menuChild of this.menu.children()) {
-      menuChild.reset()
-    }
-  }
-
-  /**
-   * Hide all the sub menus
-   */
-  hideChildren () {
-    for (let menuChild of this.menu.children()) {
-      menuChild.hideSubMenu()
-    }
-  }
-
-}
-
-class SettingsPanel extends Component {
-  constructor (player: videojs.Player, options: any) {
-    super(player, options)
-  }
-
-  createEl () {
-    return super.createEl('div', {
-      className: 'vjs-settings-panel',
-      innerHTML: '',
-      tabIndex: -1
-    })
-  }
-}
-
-class SettingsPanelChild extends Component {
-  constructor (player: videojs.Player, options: any) {
-    super(player, options)
-  }
-
-  createEl () {
-    return super.createEl('div', {
-      className: 'vjs-settings-panel-child',
-      innerHTML: '',
-      tabIndex: -1
-    })
-  }
-}
-
-class SettingsDialog extends Component {
-  constructor (player: videojs.Player, options: any) {
-    super(player, options)
-    this.hide()
-  }
-
-  /**
-   * Create the component's DOM element
-   *
-   * @return {Element}
-   * @method createEl
-   */
-  createEl () {
-    const uniqueId = this.id_
-    const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId
-    const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId
-
-    return super.createEl('div', {
-      className: 'vjs-settings-dialog vjs-modal-overlay',
-      innerHTML: '',
-      tabIndex: -1
-    }, {
-      'role': 'dialog',
-      'aria-labelledby': dialogLabelId,
-      'aria-describedby': dialogDescriptionId
-    })
-  }
-
-}
-
-SettingsButton.prototype.controlText_ = 'Settings'
-
-Component.registerComponent('SettingsButton', SettingsButton)
-Component.registerComponent('SettingsDialog', SettingsDialog)
-Component.registerComponent('SettingsPanel', SettingsPanel)
-Component.registerComponent('SettingsPanelChild', SettingsPanelChild)
-
-export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild }
diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts
deleted file mode 100644 (file)
index 2a3460a..0000000
+++ /dev/null
@@ -1,332 +0,0 @@
-// Author: Yanko Shterev
-// Thanks https://github.com/yshterev/videojs-settings-menu
-
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as videojs from 'video.js'
-
-import { toTitleCase } from './utils'
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-
-const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
-const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
-
-class SettingsMenuItem extends MenuItem {
-
-  constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) {
-    super(player, options)
-
-    this.settingsButton = menuButton
-    this.dialog = this.settingsButton.dialog
-    this.mainMenu = this.settingsButton.menu
-    this.panel = this.dialog.getChild('settingsPanel')
-    this.panelChild = this.panel.getChild('settingsPanelChild')
-    this.panelChildEl = this.panelChild.el_
-
-    this.size = null
-
-    // keep state of what menu type is loading next
-    this.menuToLoad = 'mainmenu'
-
-    const subMenuName = toTitleCase(entry)
-    const SubMenuComponent = videojsUntyped.getComponent(subMenuName)
-
-    if (!SubMenuComponent) {
-      throw new Error(`Component ${subMenuName} does not exist`)
-    }
-    this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this)
-    const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0]
-    this.settingsSubMenuEl_.className += ' ' + subMenuClass
-
-    this.eventHandlers()
-
-    player.ready(() => {
-      // Voodoo magic for IOS
-      setTimeout(() => {
-        this.build()
-
-        // Update on rate change
-        player.on('ratechange', this.submenuClickHandler)
-
-        if (subMenuName === 'CaptionsButton') {
-          // Hack to regenerate captions on HTTP fallback
-          player.on('captionsChanged', () => {
-            setTimeout(() => {
-              this.settingsSubMenuEl_.innerHTML = ''
-              this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
-              this.update()
-              this.bindClickEvents()
-
-            }, 0)
-          })
-        }
-
-        this.reset()
-      }, 0)
-    })
-  }
-
-  eventHandlers () {
-    this.submenuClickHandler = this.onSubmenuClick.bind(this)
-    this.transitionEndHandler = this.onTransitionEnd.bind(this)
-  }
-
-  onSubmenuClick (event: any) {
-    let target = null
-
-    if (event.type === 'tap') {
-      target = event.target
-    } else {
-      target = event.currentTarget
-    }
-
-    if (target && target.classList.contains('vjs-back-button')) {
-      this.loadMainMenu()
-      return
-    }
-
-    // To update the sub menu value on click, setTimeout is needed because
-    // updating the value is not instant
-    setTimeout(() => this.update(event), 0)
-  }
-
-  /**
-   * Create the component's DOM element
-   *
-   * @return {Element}
-   * @method createEl
-   */
-  createEl () {
-    const el = videojsUntyped.dom.createEl('li', {
-      className: 'vjs-menu-item'
-    })
-
-    this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-settings-sub-menu-title'
-    })
-
-    el.appendChild(this.settingsSubMenuTitleEl_)
-
-    this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-settings-sub-menu-value'
-    })
-
-    el.appendChild(this.settingsSubMenuValueEl_)
-
-    this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-settings-sub-menu'
-    })
-
-    return el
-  }
-
-  /**
-   * Handle click on menu item
-   *
-   * @method handleClick
-   */
-  handleClick () {
-    this.menuToLoad = 'submenu'
-    // Remove open class to ensure only the open submenu gets this class
-    videojsUntyped.dom.removeClass(this.el_, 'open')
-
-    super.handleClick()
-
-    this.mainMenu.el_.style.opacity = '0'
-    // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element
-    if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) {
-      videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
-
-      // animation not played without timeout
-      setTimeout(() => {
-        this.settingsSubMenuEl_.style.opacity = '1'
-        this.settingsSubMenuEl_.style.marginRight = '0px'
-      }, 0)
-
-      this.settingsButton.setDialogSize(this.size)
-    } else {
-      videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
-    }
-  }
-
-  /**
-   * Create back button
-   *
-   * @method createBackButton
-   */
-  createBackButton () {
-    const button = this.subMenu.menu.addChild('MenuItem', {}, 0)
-    button.name_ = 'BackButton'
-    button.addClass('vjs-back-button')
-    button.el_.innerHTML = this.player_.localize(this.subMenu.controlText_)
-  }
-
-  /**
-   * Add/remove prefixed event listener for CSS Transition
-   *
-   * @method PrefixedEvent
-   */
-  PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
-    let prefix = ['webkit', 'moz', 'MS', 'o', '']
-
-    for (let p = 0; p < prefix.length; p++) {
-      if (!prefix[p]) {
-        type = type.toLowerCase()
-      }
-
-      if (action === 'addEvent') {
-        element.addEventListener(prefix[p] + type, callback, false)
-      } else if (action === 'removeEvent') {
-        element.removeEventListener(prefix[p] + type, callback, false)
-      }
-    }
-  }
-
-  onTransitionEnd (event: any) {
-    if (event.propertyName !== 'margin-right') {
-      return
-    }
-
-    if (this.menuToLoad === 'mainmenu') {
-      // hide submenu
-      videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
-
-      // reset opacity to 0
-      this.settingsSubMenuEl_.style.opacity = '0'
-    }
-  }
-
-  reset () {
-    videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
-    this.settingsSubMenuEl_.style.opacity = '0'
-    this.setMargin()
-  }
-
-  loadMainMenu () {
-    this.menuToLoad = 'mainmenu'
-    this.mainMenu.show()
-    this.mainMenu.el_.style.opacity = '0'
-
-    // back button will always take you to main menu, so set dialog sizes
-    this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height])
-
-    // animation not triggered without timeout (some async stuff ?!?)
-    setTimeout(() => {
-      // animate margin and opacity before hiding the submenu
-      // this triggers CSS Transition event
-      this.setMargin()
-      this.mainMenu.el_.style.opacity = '1'
-    }, 0)
-  }
-
-  build () {
-    const saveUpdateLabel = this.subMenu.updateLabel
-    this.subMenu.updateLabel = () => {
-      this.update()
-
-      saveUpdateLabel.call(this.subMenu)
-    }
-
-    this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_)
-    this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
-    this.panelChildEl.appendChild(this.settingsSubMenuEl_)
-    this.update()
-
-    this.createBackButton()
-    this.getSize()
-    this.bindClickEvents()
-
-    // prefixed event listeners for CSS TransitionEnd
-    this.PrefixedEvent(
-      this.settingsSubMenuEl_,
-      'TransitionEnd',
-      this.transitionEndHandler,
-      'addEvent'
-    )
-  }
-
-  update (event?: any) {
-    let target: HTMLElement = null
-    let subMenu = this.subMenu.name()
-
-    if (event && event.type === 'tap') {
-      target = event.target
-    } else if (event) {
-      target = event.currentTarget
-    }
-
-    // Playback rate menu button doesn't get a vjs-selected class
-    // or sets options_['selected'] on the selected playback rate.
-    // Thus we get the submenu value based on the labelEl of playbackRateMenuButton
-    if (subMenu === 'PlaybackRateMenuButton') {
-      setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250)
-    } else {
-      // Loop trough the submenu items to find the selected child
-      for (let subMenuItem of this.subMenu.menu.children_) {
-        if (!(subMenuItem instanceof component)) {
-          continue
-        }
-
-        if (subMenuItem.hasClass('vjs-selected')) {
-          // Prefer to use the function
-          if (typeof subMenuItem.getLabel === 'function') {
-            this.settingsSubMenuValueEl_.innerHTML = subMenuItem.getLabel()
-            break
-          }
-
-          this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label
-        }
-      }
-    }
-
-    if (target && !target.classList.contains('vjs-back-button')) {
-      this.settingsButton.hideDialog()
-    }
-  }
-
-  bindClickEvents () {
-    for (let item of this.subMenu.menu.children()) {
-      if (!(item instanceof component)) {
-        continue
-      }
-      item.on(['tap', 'click'], this.submenuClickHandler)
-    }
-  }
-
-  // save size of submenus on first init
-  // if number of submenu items change dynamically more logic will be needed
-  getSize () {
-    this.dialog.removeClass('vjs-hidden')
-    this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_)
-    this.setMargin()
-    this.dialog.addClass('vjs-hidden')
-    videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
-  }
-
-  setMargin () {
-    let [width] = this.size
-
-    this.settingsSubMenuEl_.style.marginRight = `-${width}px`
-  }
-
-  /**
-   * Hide the sub menu
-   */
-  hideSubMenu () {
-    // after removing settings item this.el_ === null
-    if (!this.el_) {
-      return
-    }
-
-    if (videojsUntyped.dom.hasClass(this.el_, 'open')) {
-      videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
-      videojsUntyped.dom.removeClass(this.el_, 'open')
-    }
-  }
-
-}
-
-SettingsMenuItem.prototype.contentElType = 'button'
-videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem)
-
-export { SettingsMenuItem }
diff --git a/client/src/assets/player/theater-button.ts b/client/src/assets/player/theater-button.ts
deleted file mode 100644 (file)
index 4f8fede..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as videojs from 'video.js'
-
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { saveTheaterInStore, getStoredTheater } from './peertube-player-local-storage'
-
-const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
-class TheaterButton extends Button {
-
-  private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled'
-
-  constructor (player: videojs.Player, options: any) {
-    super(player, options)
-
-    const enabled = getStoredTheater()
-    if (enabled === true) {
-      this.player_.addClass(TheaterButton.THEATER_MODE_CLASS)
-      this.handleTheaterChange()
-    }
-  }
-
-  buildCSSClass () {
-    return `vjs-theater-control ${super.buildCSSClass()}`
-  }
-
-  handleTheaterChange () {
-    if (this.isTheaterEnabled()) {
-      this.controlText('Normal mode')
-    } else {
-      this.controlText('Theater mode')
-    }
-
-    saveTheaterInStore(this.isTheaterEnabled())
-  }
-
-  handleClick () {
-    this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS)
-
-    this.handleTheaterChange()
-  }
-
-  private isTheaterEnabled () {
-    return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS)
-  }
-}
-
-TheaterButton.prototype.controlText_ = 'Theater mode'
-
-TheaterButton.registerComponent('TheaterButton', TheaterButton)
diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/video-renderer.ts
deleted file mode 100644 (file)
index a341593..0000000
+++ /dev/null
@@ -1,134 +0,0 @@
-// Thanks: https://github.com/feross/render-media
-// TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed
-
-const MediaElementWrapper = require('mediasource')
-import { extname } from 'path'
-const videostream = require('videostream')
-
-const VIDEOSTREAM_EXTS = [
-  '.m4a',
-  '.m4v',
-  '.mp4'
-]
-
-type RenderMediaOptions = {
-  controls: boolean
-  autoplay: boolean
-}
-
-function renderVideo (
-  file: any,
-  elem: HTMLVideoElement,
-  opts: RenderMediaOptions,
-  callback: (err: Error, renderer: any) => void
-) {
-  validateFile(file)
-
-  return renderMedia(file, elem, opts, callback)
-}
-
-function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) {
-  const extension = extname(file.name).toLowerCase()
-  let preparedElem: any = undefined
-  let currentTime = 0
-  let renderer: any
-
-  try {
-    if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) {
-      renderer = useVideostream()
-    } else {
-      renderer = useMediaSource()
-    }
-  } catch (err) {
-    return callback(err)
-  }
-
-  function useVideostream () {
-    prepareElem()
-    preparedElem.addEventListener('error', function onError (err: Error) {
-      preparedElem.removeEventListener('error', onError)
-
-      return callback(err)
-    })
-    preparedElem.addEventListener('loadstart', onLoadStart)
-    return videostream(file, preparedElem)
-  }
-
-  function useMediaSource (useVP9 = false) {
-    const codecs = getCodec(file.name, useVP9)
-
-    prepareElem()
-    preparedElem.addEventListener('error', function onError (err: Error) {
-      preparedElem.removeEventListener('error', onError)
-
-      // Try with vp9 before returning an error
-      if (codecs.indexOf('vp8') !== -1) return fallbackToMediaSource(true)
-
-      return callback(err)
-    })
-    preparedElem.addEventListener('loadstart', onLoadStart)
-
-    const wrapper = new MediaElementWrapper(preparedElem)
-    const writable = wrapper.createWriteStream(codecs)
-    file.createReadStream().pipe(writable)
-
-    if (currentTime) preparedElem.currentTime = currentTime
-
-    return wrapper
-  }
-
-  function fallbackToMediaSource (useVP9 = false) {
-    if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.')
-    else console.log('Falling back to media source..')
-
-    useMediaSource(useVP9)
-  }
-
-  function prepareElem () {
-    if (preparedElem === undefined) {
-      preparedElem = elem
-
-      preparedElem.addEventListener('progress', function () {
-        currentTime = elem.currentTime
-      })
-    }
-  }
-
-  function onLoadStart () {
-    preparedElem.removeEventListener('loadstart', onLoadStart)
-    if (opts.autoplay) preparedElem.play()
-
-    callback(null, renderer)
-  }
-}
-
-function validateFile (file: any) {
-  if (file == null) {
-    throw new Error('file cannot be null or undefined')
-  }
-  if (typeof file.name !== 'string') {
-    throw new Error('missing or invalid file.name property')
-  }
-  if (typeof file.createReadStream !== 'function') {
-    throw new Error('missing or invalid file.createReadStream property')
-  }
-}
-
-function getCodec (name: string, useVP9 = false) {
-  const ext = extname(name).toLowerCase()
-  if (ext === '.mp4') {
-    return 'video/mp4; codecs="avc1.640029, mp4a.40.5"'
-  }
-
-  if (ext === '.webm') {
-    if (useVP9 === true) return 'video/webm; codecs="vp9, opus"'
-
-    return 'video/webm; codecs="vp8, vorbis"'
-  }
-
-  return undefined
-}
-
-export {
-  renderVideo
-}
diff --git a/client/src/assets/player/videojs-components/p2p-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts
new file mode 100644 (file)
index 0000000..03a5d29
--- /dev/null
@@ -0,0 +1,102 @@
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+import { bytes } from '../utils'
+
+const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
+class P2pInfoButton extends Button {
+
+  createEl () {
+    const div = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-peertube'
+    })
+    const subDivWebtorrent = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-peertube-hidden' // Hide the stats before we get the info
+    })
+    div.appendChild(subDivWebtorrent)
+
+    const downloadIcon = videojsUntyped.dom.createEl('span', {
+      className: 'icon icon-download'
+    })
+    subDivWebtorrent.appendChild(downloadIcon)
+
+    const downloadSpeedText = videojsUntyped.dom.createEl('span', {
+      className: 'download-speed-text'
+    })
+    const downloadSpeedNumber = videojsUntyped.dom.createEl('span', {
+      className: 'download-speed-number'
+    })
+    const downloadSpeedUnit = videojsUntyped.dom.createEl('span')
+    downloadSpeedText.appendChild(downloadSpeedNumber)
+    downloadSpeedText.appendChild(downloadSpeedUnit)
+    subDivWebtorrent.appendChild(downloadSpeedText)
+
+    const uploadIcon = videojsUntyped.dom.createEl('span', {
+      className: 'icon icon-upload'
+    })
+    subDivWebtorrent.appendChild(uploadIcon)
+
+    const uploadSpeedText = videojsUntyped.dom.createEl('span', {
+      className: 'upload-speed-text'
+    })
+    const uploadSpeedNumber = videojsUntyped.dom.createEl('span', {
+      className: 'upload-speed-number'
+    })
+    const uploadSpeedUnit = videojsUntyped.dom.createEl('span')
+    uploadSpeedText.appendChild(uploadSpeedNumber)
+    uploadSpeedText.appendChild(uploadSpeedUnit)
+    subDivWebtorrent.appendChild(uploadSpeedText)
+
+    const peersText = videojsUntyped.dom.createEl('span', {
+      className: 'peers-text'
+    })
+    const peersNumber = videojsUntyped.dom.createEl('span', {
+      className: 'peers-number'
+    })
+    subDivWebtorrent.appendChild(peersNumber)
+    subDivWebtorrent.appendChild(peersText)
+
+    const subDivHttp = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-peertube-hidden'
+    })
+    const subDivHttpText = videojsUntyped.dom.createEl('span', {
+      className: 'http-fallback',
+      textContent: 'HTTP'
+    })
+
+    subDivHttp.appendChild(subDivHttpText)
+    div.appendChild(subDivHttp)
+
+    this.player_.on('p2pInfo', (event: any, data: any) => {
+      // We are in HTTP fallback
+      if (!data) {
+        subDivHttp.className = 'vjs-peertube-displayed'
+        subDivWebtorrent.className = 'vjs-peertube-hidden'
+
+        return
+      }
+
+      const downloadSpeed = bytes(data.downloadSpeed)
+      const uploadSpeed = bytes(data.uploadSpeed)
+      const totalDownloaded = bytes(data.downloaded)
+      const totalUploaded = bytes(data.uploaded)
+      const numPeers = data.numPeers
+
+      subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' +
+        this.player_.localize('Total uploaded: ' + totalUploaded.join(' '))
+
+      downloadSpeedNumber.textContent = downloadSpeed[ 0 ]
+      downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ]
+
+      uploadSpeedNumber.textContent = uploadSpeed[ 0 ]
+      uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
+
+      peersNumber.textContent = numPeers
+      peersText.textContent = ' ' + this.player_.localize('peers')
+
+      subDivHttp.className = 'vjs-peertube-hidden'
+      subDivWebtorrent.className = 'vjs-peertube-displayed'
+    })
+
+    return div
+  }
+}
+Button.registerComponent('P2PInfoButton', P2pInfoButton)
diff --git a/client/src/assets/player/videojs-components/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts
new file mode 100644 (file)
index 0000000..fed8ea3
--- /dev/null
@@ -0,0 +1,40 @@
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+import { buildVideoLink } from '../utils'
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import { Player } from 'video.js'
+
+const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
+class PeerTubeLinkButton extends Button {
+
+  constructor (player: Player, options: any) {
+    super(player, options)
+  }
+
+  createEl () {
+    return this.buildElement()
+  }
+
+  updateHref () {
+    this.el().setAttribute('href', buildVideoLink(this.player().currentTime()))
+  }
+
+  handleClick () {
+    this.player_.pause()
+  }
+
+  private buildElement () {
+    const el = videojsUntyped.dom.createEl('a', {
+      href: buildVideoLink(),
+      innerHTML: 'PeerTube',
+      title: this.player_.localize('Go to the video page'),
+      className: 'vjs-peertube-link',
+      target: '_blank'
+    })
+
+    el.addEventListener('mouseenter', () => this.updateHref())
+
+    return el
+  }
+}
+Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton)
diff --git a/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts
new file mode 100644 (file)
index 0000000..9a0e3b5
--- /dev/null
@@ -0,0 +1,38 @@
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import { Player } from 'video.js'
+
+const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
+
+class PeerTubeLoadProgressBar extends Component {
+
+  constructor (player: Player, options: any) {
+    super(player, options)
+    this.partEls_ = []
+    this.on(player, 'progress', this.update)
+  }
+
+  createEl () {
+    return super.createEl('div', {
+      className: 'vjs-load-progress',
+      innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
+    })
+  }
+
+  dispose () {
+    this.partEls_ = null
+
+    super.dispose()
+  }
+
+  update () {
+    const torrent = this.player().webtorrent().getTorrent()
+    if (!torrent) return
+
+    this.el_.style.width = (torrent.progress * 100) + '%'
+  }
+
+}
+
+Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar)
diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts
new file mode 100644 (file)
index 0000000..2847de4
--- /dev/null
@@ -0,0 +1,84 @@
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import { Player } from 'video.js'
+
+import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+import { ResolutionMenuItem } from './resolution-menu-item'
+
+const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
+const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton')
+class ResolutionMenuButton extends MenuButton {
+  label: HTMLElement
+
+  constructor (player: Player, options: any) {
+    super(player, options)
+    this.player = player
+
+    player.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data))
+
+    if (player.webtorrent) {
+      player.webtorrent().on('videoFileUpdate', () => setTimeout(() => this.trigger('updateLabel'), 0))
+    }
+  }
+
+  createEl () {
+    const el = super.createEl()
+
+    this.labelEl_ = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-resolution-value'
+    })
+
+    el.appendChild(this.labelEl_)
+
+    return el
+  }
+
+  updateARIAAttributes () {
+    this.el().setAttribute('aria-label', 'Quality')
+  }
+
+  createMenu () {
+    return new Menu(this.player_)
+  }
+
+  buildCSSClass () {
+    return super.buildCSSClass() + ' vjs-resolution-button'
+  }
+
+  buildWrapperCSSClass () {
+    return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
+  }
+
+  private buildQualities (data: LoadedQualityData) {
+    // The automatic resolution item will need other labels
+    const labels: { [ id: number ]: string } = {}
+
+    for (const d of data.qualityData.video) {
+      this.menu.addChild(new ResolutionMenuItem(
+        this.player_,
+        {
+          id: d.id,
+          label: d.label,
+          selected: d.selected,
+          callback: data.qualitySwitchCallback
+        })
+      )
+
+      labels[d.id] = d.label
+    }
+
+    this.menu.addChild(new ResolutionMenuItem(
+      this.player_,
+      {
+        id: -1,
+        label: this.player_.localize('Auto'),
+        labels,
+        callback: data.qualitySwitchCallback,
+        selected: true // By default, in auto mode
+      }
+    ))
+  }
+}
+ResolutionMenuButton.prototype.controlText_ = 'Quality'
+
+MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts
new file mode 100644 (file)
index 0000000..cc1c797
--- /dev/null
@@ -0,0 +1,87 @@
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import { Player } from 'video.js'
+
+import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+
+const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
+class ResolutionMenuItem extends MenuItem {
+  private readonly id: number
+  private readonly label: string
+  // Only used for the automatic item
+  private readonly labels: { [id: number]: string }
+  private readonly callback: Function
+
+  private autoResolutionPossible: boolean
+  private currentResolutionLabel: string
+
+  constructor (player: Player, options: any) {
+    options.selectable = true
+
+    super(player, options)
+
+    this.autoResolutionPossible = true
+    this.currentResolutionLabel = ''
+
+    this.label = options.label
+    this.labels = options.labels
+    this.id = options.id
+    this.callback = options.callback
+
+    if (player.webtorrent) {
+      player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
+
+      // We only want to disable the "Auto" item
+      if (this.id === -1) {
+        player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
+      }
+    }
+
+    // TODO: update on HLS change
+  }
+
+  handleClick (event: any) {
+    // Auto button disabled?
+    if (this.autoResolutionPossible === false && this.id === -1) return
+
+    super.handleClick(event)
+
+    this.callback(this.id)
+  }
+
+  updateSelection (data: ResolutionUpdateData) {
+    if (this.id === -1) {
+      this.currentResolutionLabel = this.labels[data.resolutionId]
+    }
+
+    // Automatic resolution only
+    if (data.auto === true) {
+      this.selected(this.id === -1)
+      return
+    }
+
+    this.selected(this.id === data.resolutionId)
+  }
+
+  updateAutoResolution (data: AutoResolutionUpdateData) {
+    // Check if the auto resolution is enabled or not
+    if (data.possible === false) {
+      this.addClass('disabled')
+    } else {
+      this.removeClass('disabled')
+    }
+
+    this.autoResolutionPossible = data.possible
+  }
+
+  getLabel () {
+    if (this.id === -1) {
+      return this.label + ' <small>' + this.currentResolutionLabel + '</small>'
+    }
+
+    return this.label
+  }
+}
+MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
+
+export { ResolutionMenuItem }
diff --git a/client/src/assets/player/videojs-components/settings-menu-button.ts b/client/src/assets/player/videojs-components/settings-menu-button.ts
new file mode 100644 (file)
index 0000000..14cb8ba
--- /dev/null
@@ -0,0 +1,288 @@
+// Author: Yanko Shterev
+// Thanks https://github.com/yshterev/videojs-settings-menu
+
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+
+import { SettingsMenuItem } from './settings-menu-item'
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+import { toTitleCase } from '../utils'
+
+const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
+const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
+const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
+
+class SettingsButton extends Button {
+  constructor (player: videojs.Player, options: any) {
+    super(player, options)
+
+    this.playerComponent = player
+    this.dialog = this.playerComponent.addChild('settingsDialog')
+    this.dialogEl = this.dialog.el_
+    this.menu = null
+    this.panel = this.dialog.addChild('settingsPanel')
+    this.panelChild = this.panel.addChild('settingsPanelChild')
+
+    this.addClass('vjs-settings')
+    this.el_.setAttribute('aria-label', 'Settings Button')
+
+    // Event handlers
+    this.addSettingsItemHandler = this.onAddSettingsItem.bind(this)
+    this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this)
+    this.playerClickHandler = this.onPlayerClick.bind(this)
+    this.userInactiveHandler = this.onUserInactive.bind(this)
+
+    this.buildMenu()
+    this.bindEvents()
+
+    // Prepare the dialog
+    this.player().one('play', () => this.hideDialog())
+  }
+
+  onPlayerClick (event: MouseEvent) {
+    const element = event.target as HTMLElement
+    if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) {
+      return
+    }
+
+    if (!this.dialog.hasClass('vjs-hidden')) {
+      this.hideDialog()
+    }
+  }
+
+  onDisposeSettingsItem (event: any, name: string) {
+    if (name === undefined) {
+      let children = this.menu.children()
+
+      while (children.length > 0) {
+        children[0].dispose()
+        this.menu.removeChild(children[0])
+      }
+
+      this.addClass('vjs-hidden')
+    } else {
+      let item = this.menu.getChild(name)
+
+      if (item) {
+        item.dispose()
+        this.menu.removeChild(item)
+      }
+    }
+
+    this.hideDialog()
+
+    if (this.options_.entries.length === 0) {
+      this.addClass('vjs-hidden')
+    }
+  }
+
+  onAddSettingsItem (event: any, data: any) {
+    const [ entry, options ] = data
+
+    this.addMenuItem(entry, options)
+    this.removeClass('vjs-hidden')
+  }
+
+  onUserInactive () {
+    if (!this.dialog.hasClass('vjs-hidden')) {
+      this.hideDialog()
+    }
+  }
+
+  bindEvents () {
+    this.playerComponent.on('click', this.playerClickHandler)
+    this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler)
+    this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler)
+    this.playerComponent.on('userinactive', this.userInactiveHandler)
+  }
+
+  buildCSSClass () {
+    return `vjs-icon-settings ${super.buildCSSClass()}`
+  }
+
+  handleClick () {
+    if (this.dialog.hasClass('vjs-hidden')) {
+      this.showDialog()
+    } else {
+      this.hideDialog()
+    }
+  }
+
+  showDialog () {
+    this.menu.el_.style.opacity = '1'
+    this.dialog.show()
+
+    this.setDialogSize(this.getComponentSize(this.menu))
+  }
+
+  hideDialog () {
+    this.dialog.hide()
+    this.setDialogSize(this.getComponentSize(this.menu))
+    this.menu.el_.style.opacity = '1'
+    this.resetChildren()
+  }
+
+  getComponentSize (element: any) {
+    let width: number = null
+    let height: number = null
+
+    // Could be component or just DOM element
+    if (element instanceof Component) {
+      width = element.el_.offsetWidth
+      height = element.el_.offsetHeight
+
+      // keep width/height as properties for direct use
+      element.width = width
+      element.height = height
+    } else {
+      width = element.offsetWidth
+      height = element.offsetHeight
+    }
+
+    return [ width, height ]
+  }
+
+  setDialogSize ([ width, height ]: number[]) {
+    if (typeof height !== 'number') {
+      return
+    }
+
+    let offset = this.options_.setup.maxHeightOffset
+    let maxHeight = this.playerComponent.el_.offsetHeight - offset
+
+    if (height > maxHeight) {
+      height = maxHeight
+      width += 17
+      this.panel.el_.style.maxHeight = `${height}px`
+    } else if (this.panel.el_.style.maxHeight !== '') {
+      this.panel.el_.style.maxHeight = ''
+    }
+
+    this.dialogEl.style.width = `${width}px`
+    this.dialogEl.style.height = `${height}px`
+  }
+
+  buildMenu () {
+    this.menu = new Menu(this.player())
+    this.menu.addClass('vjs-main-menu')
+    let entries = this.options_.entries
+
+    if (entries.length === 0) {
+      this.addClass('vjs-hidden')
+      this.panelChild.addChild(this.menu)
+      return
+    }
+
+    for (let entry of entries) {
+      this.addMenuItem(entry, this.options_)
+    }
+
+    this.panelChild.addChild(this.menu)
+  }
+
+  addMenuItem (entry: any, options: any) {
+    const openSubMenu = function (this: any) {
+      if (videojsUntyped.dom.hasClass(this.el_, 'open')) {
+        videojsUntyped.dom.removeClass(this.el_, 'open')
+      } else {
+        videojsUntyped.dom.addClass(this.el_, 'open')
+      }
+    }
+
+    options.name = toTitleCase(entry)
+    let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any)
+
+    this.menu.addChild(settingsMenuItem)
+
+    // Hide children to avoid sub menus stacking on top of each other
+    // or having multiple menus open
+    settingsMenuItem.on('click', videojs.bind(this, this.hideChildren))
+
+    // Whether to add or remove selected class on the settings sub menu element
+    settingsMenuItem.on('click', openSubMenu)
+  }
+
+  resetChildren () {
+    for (let menuChild of this.menu.children()) {
+      menuChild.reset()
+    }
+  }
+
+  /**
+   * Hide all the sub menus
+   */
+  hideChildren () {
+    for (let menuChild of this.menu.children()) {
+      menuChild.hideSubMenu()
+    }
+  }
+
+}
+
+class SettingsPanel extends Component {
+  constructor (player: videojs.Player, options: any) {
+    super(player, options)
+  }
+
+  createEl () {
+    return super.createEl('div', {
+      className: 'vjs-settings-panel',
+      innerHTML: '',
+      tabIndex: -1
+    })
+  }
+}
+
+class SettingsPanelChild extends Component {
+  constructor (player: videojs.Player, options: any) {
+    super(player, options)
+  }
+
+  createEl () {
+    return super.createEl('div', {
+      className: 'vjs-settings-panel-child',
+      innerHTML: '',
+      tabIndex: -1
+    })
+  }
+}
+
+class SettingsDialog extends Component {
+  constructor (player: videojs.Player, options: any) {
+    super(player, options)
+    this.hide()
+  }
+
+  /**
+   * Create the component's DOM element
+   *
+   * @return {Element}
+   * @method createEl
+   */
+  createEl () {
+    const uniqueId = this.id_
+    const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId
+    const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId
+
+    return super.createEl('div', {
+      className: 'vjs-settings-dialog vjs-modal-overlay',
+      innerHTML: '',
+      tabIndex: -1
+    }, {
+      'role': 'dialog',
+      'aria-labelledby': dialogLabelId,
+      'aria-describedby': dialogDescriptionId
+    })
+  }
+
+}
+
+SettingsButton.prototype.controlText_ = 'Settings'
+
+Component.registerComponent('SettingsButton', SettingsButton)
+Component.registerComponent('SettingsDialog', SettingsDialog)
+Component.registerComponent('SettingsPanel', SettingsPanel)
+Component.registerComponent('SettingsPanelChild', SettingsPanelChild)
+
+export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild }
diff --git a/client/src/assets/player/videojs-components/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts
new file mode 100644 (file)
index 0000000..b9a4302
--- /dev/null
@@ -0,0 +1,329 @@
+// Author: Yanko Shterev
+// Thanks https://github.com/yshterev/videojs-settings-menu
+
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+
+import { toTitleCase } from '../utils'
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+
+const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
+const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
+
+class SettingsMenuItem extends MenuItem {
+
+  constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) {
+    super(player, options)
+
+    this.settingsButton = menuButton
+    this.dialog = this.settingsButton.dialog
+    this.mainMenu = this.settingsButton.menu
+    this.panel = this.dialog.getChild('settingsPanel')
+    this.panelChild = this.panel.getChild('settingsPanelChild')
+    this.panelChildEl = this.panelChild.el_
+
+    this.size = null
+
+    // keep state of what menu type is loading next
+    this.menuToLoad = 'mainmenu'
+
+    const subMenuName = toTitleCase(entry)
+    const SubMenuComponent = videojsUntyped.getComponent(subMenuName)
+
+    if (!SubMenuComponent) {
+      throw new Error(`Component ${subMenuName} does not exist`)
+    }
+    this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this)
+    const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0]
+    this.settingsSubMenuEl_.className += ' ' + subMenuClass
+
+    this.eventHandlers()
+
+    player.ready(() => {
+      // Voodoo magic for IOS
+      setTimeout(() => {
+        this.build()
+
+        // Update on rate change
+        player.on('ratechange', this.submenuClickHandler)
+
+        if (subMenuName === 'CaptionsButton') {
+          // Hack to regenerate captions on HTTP fallback
+          player.on('captionsChanged', () => {
+            setTimeout(() => {
+              this.settingsSubMenuEl_.innerHTML = ''
+              this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
+              this.update()
+              this.bindClickEvents()
+
+            }, 0)
+          })
+        }
+
+        this.reset()
+      }, 0)
+    })
+  }
+
+  eventHandlers () {
+    this.submenuClickHandler = this.onSubmenuClick.bind(this)
+    this.transitionEndHandler = this.onTransitionEnd.bind(this)
+  }
+
+  onSubmenuClick (event: any) {
+    let target = null
+
+    if (event.type === 'tap') {
+      target = event.target
+    } else {
+      target = event.currentTarget
+    }
+
+    if (target && target.classList.contains('vjs-back-button')) {
+      this.loadMainMenu()
+      return
+    }
+
+    // To update the sub menu value on click, setTimeout is needed because
+    // updating the value is not instant
+    setTimeout(() => this.update(event), 0)
+  }
+
+  /**
+   * Create the component's DOM element
+   *
+   * @return {Element}
+   * @method createEl
+   */
+  createEl () {
+    const el = videojsUntyped.dom.createEl('li', {
+      className: 'vjs-menu-item'
+    })
+
+    this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-settings-sub-menu-title'
+    })
+
+    el.appendChild(this.settingsSubMenuTitleEl_)
+
+    this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-settings-sub-menu-value'
+    })
+
+    el.appendChild(this.settingsSubMenuValueEl_)
+
+    this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-settings-sub-menu'
+    })
+
+    return el
+  }
+
+  /**
+   * Handle click on menu item
+   *
+   * @method handleClick
+   */
+  handleClick () {
+    this.menuToLoad = 'submenu'
+    // Remove open class to ensure only the open submenu gets this class
+    videojsUntyped.dom.removeClass(this.el_, 'open')
+
+    super.handleClick()
+
+    this.mainMenu.el_.style.opacity = '0'
+    // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element
+    if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) {
+      videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
+
+      // animation not played without timeout
+      setTimeout(() => {
+        this.settingsSubMenuEl_.style.opacity = '1'
+        this.settingsSubMenuEl_.style.marginRight = '0px'
+      }, 0)
+
+      this.settingsButton.setDialogSize(this.size)
+    } else {
+      videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
+    }
+  }
+
+  /**
+   * Create back button
+   *
+   * @method createBackButton
+   */
+  createBackButton () {
+    const button = this.subMenu.menu.addChild('MenuItem', {}, 0)
+    button.name_ = 'BackButton'
+    button.addClass('vjs-back-button')
+    button.el_.innerHTML = this.player_.localize(this.subMenu.controlText_)
+  }
+
+  /**
+   * Add/remove prefixed event listener for CSS Transition
+   *
+   * @method PrefixedEvent
+   */
+  PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
+    let prefix = ['webkit', 'moz', 'MS', 'o', '']
+
+    for (let p = 0; p < prefix.length; p++) {
+      if (!prefix[p]) {
+        type = type.toLowerCase()
+      }
+
+      if (action === 'addEvent') {
+        element.addEventListener(prefix[p] + type, callback, false)
+      } else if (action === 'removeEvent') {
+        element.removeEventListener(prefix[p] + type, callback, false)
+      }
+    }
+  }
+
+  onTransitionEnd (event: any) {
+    if (event.propertyName !== 'margin-right') {
+      return
+    }
+
+    if (this.menuToLoad === 'mainmenu') {
+      // hide submenu
+      videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
+
+      // reset opacity to 0
+      this.settingsSubMenuEl_.style.opacity = '0'
+    }
+  }
+
+  reset () {
+    videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
+    this.settingsSubMenuEl_.style.opacity = '0'
+    this.setMargin()
+  }
+
+  loadMainMenu () {
+    this.menuToLoad = 'mainmenu'
+    this.mainMenu.show()
+    this.mainMenu.el_.style.opacity = '0'
+
+    // back button will always take you to main menu, so set dialog sizes
+    this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height])
+
+    // animation not triggered without timeout (some async stuff ?!?)
+    setTimeout(() => {
+      // animate margin and opacity before hiding the submenu
+      // this triggers CSS Transition event
+      this.setMargin()
+      this.mainMenu.el_.style.opacity = '1'
+    }, 0)
+  }
+
+  build () {
+    this.subMenu.on('updateLabel', () => {
+      this.update()
+    })
+
+    this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_)
+    this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
+    this.panelChildEl.appendChild(this.settingsSubMenuEl_)
+    this.update()
+
+    this.createBackButton()
+    this.getSize()
+    this.bindClickEvents()
+
+    // prefixed event listeners for CSS TransitionEnd
+    this.PrefixedEvent(
+      this.settingsSubMenuEl_,
+      'TransitionEnd',
+      this.transitionEndHandler,
+      'addEvent'
+    )
+  }
+
+  update (event?: any) {
+    let target: HTMLElement = null
+    let subMenu = this.subMenu.name()
+
+    if (event && event.type === 'tap') {
+      target = event.target
+    } else if (event) {
+      target = event.currentTarget
+    }
+
+    // Playback rate menu button doesn't get a vjs-selected class
+    // or sets options_['selected'] on the selected playback rate.
+    // Thus we get the submenu value based on the labelEl of playbackRateMenuButton
+    if (subMenu === 'PlaybackRateMenuButton') {
+      setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250)
+    } else {
+      // Loop trough the submenu items to find the selected child
+      for (let subMenuItem of this.subMenu.menu.children_) {
+        if (!(subMenuItem instanceof component)) {
+          continue
+        }
+
+        if (subMenuItem.hasClass('vjs-selected')) {
+          // Prefer to use the function
+          if (typeof subMenuItem.getLabel === 'function') {
+            this.settingsSubMenuValueEl_.innerHTML = subMenuItem.getLabel()
+            break
+          }
+
+          this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label
+        }
+      }
+    }
+
+    if (target && !target.classList.contains('vjs-back-button')) {
+      this.settingsButton.hideDialog()
+    }
+  }
+
+  bindClickEvents () {
+    for (let item of this.subMenu.menu.children()) {
+      if (!(item instanceof component)) {
+        continue
+      }
+      item.on(['tap', 'click'], this.submenuClickHandler)
+    }
+  }
+
+  // save size of submenus on first init
+  // if number of submenu items change dynamically more logic will be needed
+  getSize () {
+    this.dialog.removeClass('vjs-hidden')
+    this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_)
+    this.setMargin()
+    this.dialog.addClass('vjs-hidden')
+    videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
+  }
+
+  setMargin () {
+    let [width] = this.size
+
+    this.settingsSubMenuEl_.style.marginRight = `-${width}px`
+  }
+
+  /**
+   * Hide the sub menu
+   */
+  hideSubMenu () {
+    // after removing settings item this.el_ === null
+    if (!this.el_) {
+      return
+    }
+
+    if (videojsUntyped.dom.hasClass(this.el_, 'open')) {
+      videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
+      videojsUntyped.dom.removeClass(this.el_, 'open')
+    }
+  }
+
+}
+
+SettingsMenuItem.prototype.contentElType = 'button'
+videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem)
+
+export { SettingsMenuItem }
diff --git a/client/src/assets/player/videojs-components/theater-button.ts b/client/src/assets/player/videojs-components/theater-button.ts
new file mode 100644 (file)
index 0000000..1e11a95
--- /dev/null
@@ -0,0 +1,50 @@
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage'
+
+const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
+class TheaterButton extends Button {
+
+  private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled'
+
+  constructor (player: videojs.Player, options: any) {
+    super(player, options)
+
+    const enabled = getStoredTheater()
+    if (enabled === true) {
+      this.player_.addClass(TheaterButton.THEATER_MODE_CLASS)
+      this.handleTheaterChange()
+    }
+  }
+
+  buildCSSClass () {
+    return `vjs-theater-control ${super.buildCSSClass()}`
+  }
+
+  handleTheaterChange () {
+    if (this.isTheaterEnabled()) {
+      this.controlText('Normal mode')
+    } else {
+      this.controlText('Theater mode')
+    }
+
+    saveTheaterInStore(this.isTheaterEnabled())
+  }
+
+  handleClick () {
+    this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS)
+
+    this.handleTheaterChange()
+  }
+
+  private isTheaterEnabled () {
+    return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS)
+  }
+}
+
+TheaterButton.prototype.controlText_ = 'Theater mode'
+
+TheaterButton.registerComponent('TheaterButton', TheaterButton)
diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts
deleted file mode 100644 (file)
index c3c1af9..0000000
+++ /dev/null
@@ -1,102 +0,0 @@
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-import { bytes } from './utils'
-
-const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
-class WebtorrentInfoButton extends Button {
-
-  createEl () {
-    const div = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-peertube'
-    })
-    const subDivWebtorrent = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-peertube-hidden' // Hide the stats before we get the info
-    })
-    div.appendChild(subDivWebtorrent)
-
-    const downloadIcon = videojsUntyped.dom.createEl('span', {
-      className: 'icon icon-download'
-    })
-    subDivWebtorrent.appendChild(downloadIcon)
-
-    const downloadSpeedText = videojsUntyped.dom.createEl('span', {
-      className: 'download-speed-text'
-    })
-    const downloadSpeedNumber = videojsUntyped.dom.createEl('span', {
-      className: 'download-speed-number'
-    })
-    const downloadSpeedUnit = videojsUntyped.dom.createEl('span')
-    downloadSpeedText.appendChild(downloadSpeedNumber)
-    downloadSpeedText.appendChild(downloadSpeedUnit)
-    subDivWebtorrent.appendChild(downloadSpeedText)
-
-    const uploadIcon = videojsUntyped.dom.createEl('span', {
-      className: 'icon icon-upload'
-    })
-    subDivWebtorrent.appendChild(uploadIcon)
-
-    const uploadSpeedText = videojsUntyped.dom.createEl('span', {
-      className: 'upload-speed-text'
-    })
-    const uploadSpeedNumber = videojsUntyped.dom.createEl('span', {
-      className: 'upload-speed-number'
-    })
-    const uploadSpeedUnit = videojsUntyped.dom.createEl('span')
-    uploadSpeedText.appendChild(uploadSpeedNumber)
-    uploadSpeedText.appendChild(uploadSpeedUnit)
-    subDivWebtorrent.appendChild(uploadSpeedText)
-
-    const peersText = videojsUntyped.dom.createEl('span', {
-      className: 'peers-text'
-    })
-    const peersNumber = videojsUntyped.dom.createEl('span', {
-      className: 'peers-number'
-    })
-    subDivWebtorrent.appendChild(peersNumber)
-    subDivWebtorrent.appendChild(peersText)
-
-    const subDivHttp = videojsUntyped.dom.createEl('div', {
-      className: 'vjs-peertube-hidden'
-    })
-    const subDivHttpText = videojsUntyped.dom.createEl('span', {
-      className: 'http-fallback',
-      textContent: 'HTTP'
-    })
-
-    subDivHttp.appendChild(subDivHttpText)
-    div.appendChild(subDivHttp)
-
-    this.player_.peertube().on('torrentInfo', (event: any, data: any) => {
-      // We are in HTTP fallback
-      if (!data) {
-        subDivHttp.className = 'vjs-peertube-displayed'
-        subDivWebtorrent.className = 'vjs-peertube-hidden'
-
-        return
-      }
-
-      const downloadSpeed = bytes(data.downloadSpeed)
-      const uploadSpeed = bytes(data.uploadSpeed)
-      const totalDownloaded = bytes(data.downloaded)
-      const totalUploaded = bytes(data.uploaded)
-      const numPeers = data.numPeers
-
-      subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' +
-        this.player_.localize('Total uploaded: ' + totalUploaded.join(' '))
-
-      downloadSpeedNumber.textContent = downloadSpeed[ 0 ]
-      downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ]
-
-      uploadSpeedNumber.textContent = uploadSpeed[ 0 ]
-      uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
-
-      peersNumber.textContent = numPeers
-      peersText.textContent = ' ' + this.player_.localize('peers')
-
-      subDivHttp.className = 'vjs-peertube-hidden'
-      subDivWebtorrent.className = 'vjs-peertube-displayed'
-    })
-
-    return div
-  }
-}
-Button.registerComponent('WebTorrentButton', WebtorrentInfoButton)
diff --git a/client/src/assets/player/webtorrent-plugin.ts b/client/src/assets/player/webtorrent-plugin.ts
new file mode 100644 (file)
index 0000000..c3d990a
--- /dev/null
@@ -0,0 +1,640 @@
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import * as videojs from 'video.js'
+
+import * as WebTorrent from 'webtorrent'
+import { VideoFile } from '../../../../shared/models/videos/video.model'
+import { renderVideo } from './webtorrent/video-renderer'
+import { LoadedQualityData, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings'
+import { videoFileMaxByResolution, videoFileMinByResolution } from './utils'
+import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store'
+import {
+  getAverageBandwidthInStore,
+  getStoredMute,
+  getStoredVolume,
+  getStoredWebTorrentEnabled,
+  saveAverageBandwidth
+} from './peertube-player-local-storage'
+
+const CacheChunkStore = require('cache-chunk-store')
+
+type PlayOptions = {
+  forcePlay?: boolean,
+  seek?: number,
+  delay?: number
+}
+
+const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
+class WebTorrentPlugin extends Plugin {
+  private readonly playerElement: HTMLVideoElement
+
+  private readonly autoplay: boolean = false
+  private readonly startTime: number = 0
+  private readonly savePlayerSrcFunction: Function
+  private readonly videoFiles: VideoFile[]
+  private readonly videoDuration: number
+  private readonly CONSTANTS = {
+    INFO_SCHEDULER: 1000, // Don't change this
+    AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
+    AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
+    AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
+    AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
+    BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
+  }
+
+  private readonly webtorrent = new WebTorrent({
+    tracker: {
+      rtcConfig: {
+        iceServers: [
+          {
+            urls: 'stun:stun.stunprotocol.org'
+          },
+          {
+            urls: 'stun:stun.framasoft.org'
+          }
+        ]
+      }
+    },
+    dht: false
+  })
+
+  private player: any
+  private currentVideoFile: VideoFile
+  private torrent: WebTorrent.Torrent
+
+  private renderer: any
+  private fakeRenderer: any
+  private destroyingFakeRenderer = false
+
+  private autoResolution = true
+  private autoResolutionPossible = true
+  private isAutoResolutionObservation = false
+  private playerRefusedP2P = false
+
+  private torrentInfoInterval: any
+  private autoQualityInterval: any
+  private addTorrentDelay: any
+  private qualityObservationTimer: any
+  private runAutoQualitySchedulerTimer: any
+
+  private downloadSpeeds: number[] = []
+
+  constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
+    super(player, options)
+
+    // Disable auto play on iOS
+    this.autoplay = options.autoplay && this.isIOS() === false
+    this.playerRefusedP2P = !getStoredWebTorrentEnabled()
+
+    this.videoFiles = options.videoFiles
+    this.videoDuration = options.videoDuration
+
+    this.savePlayerSrcFunction = this.player.src
+    this.playerElement = options.playerElement
+
+    this.player.ready(() => {
+      const playerOptions = this.player.options_
+
+      const volume = getStoredVolume()
+      if (volume !== undefined) this.player.volume(volume)
+
+      const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
+      if (muted !== undefined) this.player.muted(muted)
+
+      this.player.duration(options.videoDuration)
+
+      this.initializePlayer()
+      this.runTorrentInfoScheduler()
+
+      this.player.one('play', () => {
+        // Don't run immediately scheduler, wait some seconds the TCP connections are made
+        this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
+      })
+    })
+  }
+
+  dispose () {
+    clearTimeout(this.addTorrentDelay)
+    clearTimeout(this.qualityObservationTimer)
+    clearTimeout(this.runAutoQualitySchedulerTimer)
+
+    clearInterval(this.torrentInfoInterval)
+    clearInterval(this.autoQualityInterval)
+
+    // Don't need to destroy renderer, video player will be destroyed
+    this.flushVideoFile(this.currentVideoFile, false)
+
+    this.destroyFakeRenderer()
+  }
+
+  getCurrentResolutionId () {
+    return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
+  }
+
+  updateVideoFile (
+    videoFile?: VideoFile,
+    options: {
+      forcePlay?: boolean,
+      seek?: number,
+      delay?: number
+    } = {},
+    done: () => void = () => { /* empty */ }
+  ) {
+    // Automatically choose the adapted video file
+    if (videoFile === undefined) {
+      const savedAverageBandwidth = getAverageBandwidthInStore()
+      videoFile = savedAverageBandwidth
+        ? this.getAppropriateFile(savedAverageBandwidth)
+        : this.pickAverageVideoFile()
+    }
+
+    // Don't add the same video file once again
+    if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
+      return
+    }
+
+    // Do not display error to user because we will have multiple fallback
+    this.disableErrorDisplay()
+
+    // Hack to "simulate" src link in video.js >= 6
+    // Without this, we can't play the video after pausing it
+    // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
+    this.player.src = () => true
+    const oldPlaybackRate = this.player.playbackRate()
+
+    const previousVideoFile = this.currentVideoFile
+    this.currentVideoFile = videoFile
+
+    // Don't try on iOS that does not support MediaSource
+    // Or don't use P2P if webtorrent is disabled
+    if (this.isIOS() || this.playerRefusedP2P) {
+      return this.fallbackToHttp(options, () => {
+        this.player.playbackRate(oldPlaybackRate)
+        return done()
+      })
+    }
+
+    this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
+      this.player.playbackRate(oldPlaybackRate)
+      return done()
+    })
+
+    this.changeQuality()
+    this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
+  }
+
+  updateResolution (resolutionId: number, delay = 0) {
+    // Remember player state
+    const currentTime = this.player.currentTime()
+    const isPaused = this.player.paused()
+
+    // Remove poster to have black background
+    this.playerElement.poster = ''
+
+    // Hide bigPlayButton
+    if (!isPaused) {
+      this.player.bigPlayButton.hide()
+    }
+
+    const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
+    const options = {
+      forcePlay: false,
+      delay,
+      seek: currentTime + (delay / 1000)
+    }
+    this.updateVideoFile(newVideoFile, options)
+  }
+
+  flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
+    if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
+      if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
+
+      this.webtorrent.remove(videoFile.magnetUri)
+      console.log('Removed ' + videoFile.magnetUri)
+    }
+  }
+
+  enableAutoResolution () {
+    this.autoResolution = true
+    this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
+  }
+
+  disableAutoResolution (forbid = false) {
+    if (forbid === true) this.autoResolutionPossible = false
+
+    this.autoResolution = false
+    this.trigger('autoResolutionUpdate', { possible: this.autoResolutionPossible })
+    this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
+  }
+
+  getTorrent () {
+    return this.torrent
+  }
+
+  private addTorrent (
+    magnetOrTorrentUrl: string,
+    previousVideoFile: VideoFile,
+    options: PlayOptions,
+    done: Function
+  ) {
+    console.log('Adding ' + magnetOrTorrentUrl + '.')
+
+    const oldTorrent = this.torrent
+    const torrentOptions = {
+      store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
+        max: 100
+      })
+    }
+
+    this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
+      console.log('Added ' + magnetOrTorrentUrl + '.')
+
+      if (oldTorrent) {
+        // Pause the old torrent
+        this.stopTorrent(oldTorrent)
+
+        // We use a fake renderer so we download correct pieces of the next file
+        if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
+      }
+
+      // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
+      this.addTorrentDelay = setTimeout(() => {
+        // We don't need the fake renderer anymore
+        this.destroyFakeRenderer()
+
+        const paused = this.player.paused()
+
+        this.flushVideoFile(previousVideoFile)
+
+        // Update progress bar (just for the UI), do not wait rendering
+        if (options.seek) this.player.currentTime(options.seek)
+
+        const renderVideoOptions = { autoplay: false, controls: true }
+        renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
+          this.renderer = renderer
+
+          if (err) return this.fallbackToHttp(options, done)
+
+          return this.tryToPlay(err => {
+            if (err) return done(err)
+
+            if (options.seek) this.seek(options.seek)
+            if (options.forcePlay === false && paused === true) this.player.pause()
+
+            return done()
+          })
+        })
+      }, options.delay || 0)
+    })
+
+    this.torrent.on('error', (err: any) => console.error(err))
+
+    this.torrent.on('warning', (err: any) => {
+      // We don't support HTTP tracker but we don't care -> we use the web socket tracker
+      if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
+
+      // Users don't care about issues with WebRTC, but developers do so log it in the console
+      if (err.message.indexOf('Ice connection failed') !== -1) {
+        console.log(err)
+        return
+      }
+
+      // Magnet hash is not up to date with the torrent file, add directly the torrent file
+      if (err.message.indexOf('incorrect info hash') !== -1) {
+        console.error('Incorrect info hash detected, falling back to torrent file.')
+        const newOptions = { forcePlay: true, seek: options.seek }
+        return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
+      }
+
+      // Remote instance is down
+      if (err.message.indexOf('from xs param') !== -1) {
+        this.handleError(err)
+      }
+
+      console.warn(err)
+    })
+  }
+
+  private tryToPlay (done?: (err?: Error) => void) {
+    if (!done) done = function () { /* empty */ }
+
+    const playPromise = this.player.play()
+    if (playPromise !== undefined) {
+      return playPromise.then(done)
+                        .catch((err: Error) => {
+                          if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
+                            return
+                          }
+
+                          console.error(err)
+                          this.player.pause()
+                          this.player.posterImage.show()
+                          this.player.removeClass('vjs-has-autoplay')
+                          this.player.removeClass('vjs-has-big-play-button-clicked')
+
+                          return done()
+                        })
+    }
+
+    return done()
+  }
+
+  private seek (time: number) {
+    this.player.currentTime(time)
+    this.player.handleTechSeeked_()
+  }
+
+  private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
+    if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined
+    if (this.videoFiles.length === 1) return this.videoFiles[0]
+
+    // Don't change the torrent is the play was ended
+    if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
+
+    if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
+
+    // Limit resolution according to player height
+    const playerHeight = this.playerElement.offsetHeight as number
+
+    // We take the first resolution just above the player height
+    // Example: player height is 530px, we want the 720p file instead of 480p
+    let maxResolution = this.videoFiles[0].resolution.id
+    for (let i = this.videoFiles.length - 1; i >= 0; i--) {
+      const resolutionId = this.videoFiles[i].resolution.id
+      if (resolutionId >= playerHeight) {
+        maxResolution = resolutionId
+        break
+      }
+    }
+
+    // Filter videos we can play according to our screen resolution and bandwidth
+    const filteredFiles = this.videoFiles
+                              .filter(f => f.resolution.id <= maxResolution)
+                              .filter(f => {
+                                const fileBitrate = (f.size / this.videoDuration)
+                                let threshold = fileBitrate
+
+                                // If this is for a higher resolution or an initial load: add a margin
+                                if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
+                                  threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
+                                }
+
+                                return averageDownloadSpeed > threshold
+                              })
+
+    // If the download speed is too bad, return the lowest resolution we have
+    if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles)
+
+    return videoFileMaxByResolution(filteredFiles)
+  }
+
+  private getAndSaveActualDownloadSpeed () {
+    const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
+    const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
+    if (lastDownloadSpeeds.length === 0) return -1
+
+    const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
+    const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
+
+    // Save the average bandwidth for future use
+    saveAverageBandwidth(averageBandwidth)
+
+    return averageBandwidth
+  }
+
+  private initializePlayer () {
+    this.buildQualities()
+
+    if (this.autoplay === true) {
+      this.player.posterImage.hide()
+
+      return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
+    }
+
+    // Proxy first play
+    const oldPlay = this.player.play.bind(this.player)
+    this.player.play = () => {
+      this.player.addClass('vjs-has-big-play-button-clicked')
+      this.player.play = oldPlay
+
+      this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
+    }
+  }
+
+  private runAutoQualityScheduler () {
+    this.autoQualityInterval = setInterval(() => {
+
+      // Not initialized or in HTTP fallback
+      if (this.torrent === undefined || this.torrent === null) return
+      if (this.autoResolution === false) return
+      if (this.isAutoResolutionObservation === true) return
+
+      const file = this.getAppropriateFile()
+      let changeResolution = false
+      let changeResolutionDelay = 0
+
+      // Lower resolution
+      if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
+        console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
+        changeResolution = true
+      } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
+        console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
+        changeResolution = true
+        changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
+      }
+
+      if (changeResolution === true) {
+        this.updateResolution(file.resolution.id, changeResolutionDelay)
+
+        // Wait some seconds in observation of our new resolution
+        this.isAutoResolutionObservation = true
+
+        this.qualityObservationTimer = setTimeout(() => {
+          this.isAutoResolutionObservation = false
+        }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
+      }
+    }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
+  }
+
+  private isPlayerWaiting () {
+    return this.player && this.player.hasClass('vjs-waiting')
+  }
+
+  private runTorrentInfoScheduler () {
+    this.torrentInfoInterval = setInterval(() => {
+      // Not initialized yet
+      if (this.torrent === undefined) return
+
+      // Http fallback
+      if (this.torrent === null) return this.player.trigger('p2pInfo', false)
+
+      // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
+      if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
+
+      return this.player.trigger('p2pInfo', {
+        downloadSpeed: this.torrent.downloadSpeed,
+        numPeers: this.torrent.numPeers,
+        uploadSpeed: this.torrent.uploadSpeed,
+        downloaded: this.torrent.downloaded,
+        uploaded: this.torrent.uploaded
+      })
+    }, this.CONSTANTS.INFO_SCHEDULER)
+  }
+
+  private fallbackToHttp (options: PlayOptions, done?: Function) {
+    const paused = this.player.paused()
+
+    this.disableAutoResolution(true)
+
+    this.flushVideoFile(this.currentVideoFile, true)
+    this.torrent = null
+
+    // Enable error display now this is our last fallback
+    this.player.one('error', () => this.enableErrorDisplay())
+
+    const httpUrl = this.currentVideoFile.fileUrl
+    this.player.src = this.savePlayerSrcFunction
+    this.player.src(httpUrl)
+
+    this.changeQuality()
+
+    // We changed the source, so reinit captions
+    this.player.trigger('sourcechange')
+
+    return this.tryToPlay(err => {
+      if (err && done) return done(err)
+
+      if (options.seek) this.seek(options.seek)
+      if (options.forcePlay === false && paused === true) this.player.pause()
+
+      if (done) return done()
+    })
+  }
+
+  private handleError (err: Error | string) {
+    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 isIOS () {
+    return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
+  }
+
+  private pickAverageVideoFile () {
+    if (this.videoFiles.length === 1) return this.videoFiles[0]
+
+    return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
+  }
+
+  private stopTorrent (torrent: WebTorrent.Torrent) {
+    torrent.pause()
+    // Pause does not remove actual peers (in particular the webseed peer)
+    torrent.removePeer(torrent[ 'ws' ])
+  }
+
+  private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
+    this.destroyingFakeRenderer = false
+
+    const fakeVideoElem = document.createElement('video')
+    renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
+      this.fakeRenderer = renderer
+
+      // The renderer returns an error when we destroy it, so skip them
+      if (this.destroyingFakeRenderer === false && err) {
+        console.error('Cannot render new torrent in fake video element.', err)
+      }
+
+      // Load the future file at the correct time (in delay MS - 2 seconds)
+      fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
+    })
+  }
+
+  private destroyFakeRenderer () {
+    if (this.fakeRenderer) {
+      this.destroyingFakeRenderer = true
+
+      if (this.fakeRenderer.destroy) {
+        try {
+          this.fakeRenderer.destroy()
+        } catch (err) {
+          console.log('Cannot destroy correctly fake renderer.', err)
+        }
+      }
+      this.fakeRenderer = undefined
+    }
+  }
+
+  private buildQualities () {
+    const qualityLevelsPayload = []
+
+    for (const file of this.videoFiles) {
+      const representation = {
+        id: file.resolution.id,
+        label: this.buildQualityLabel(file),
+        height: file.resolution.id,
+        _enabled: true
+      }
+
+      this.player.qualityLevels().addQualityLevel(representation)
+
+      qualityLevelsPayload.push({
+        id: representation.id,
+        label: representation.label,
+        selected: false
+      })
+    }
+
+    const payload: LoadedQualityData = {
+      qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d),
+      qualityData: {
+        video: qualityLevelsPayload
+      }
+    }
+    this.player.trigger('loadedqualitydata', payload)
+  }
+
+  private buildQualityLabel (file: VideoFile) {
+    let label = file.resolution.label
+
+    if (file.fps && file.fps >= 50) {
+      label += file.fps
+    }
+
+    return label
+  }
+
+  private qualitySwitchCallback (id: number) {
+    if (id === -1) {
+      if (this.autoResolutionPossible === true) this.enableAutoResolution()
+      return
+    }
+
+    this.disableAutoResolution()
+    this.updateResolution(id)
+  }
+
+  private changeQuality () {
+    const resolutionId = this.currentVideoFile.resolution.id
+    const qualityLevels = this.player.qualityLevels()
+
+    if (resolutionId === -1) {
+      qualityLevels.selectedIndex = -1
+      return
+    }
+
+    for (let i = 0; i < qualityLevels; i++) {
+      const q = this.player.qualityLevels[i]
+      if (q.height === resolutionId) qualityLevels.selectedIndex = i
+    }
+  }
+}
+
+videojs.registerPlugin('webtorrent', WebTorrentPlugin)
+export { WebTorrentPlugin }
diff --git a/client/src/assets/player/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/webtorrent/peertube-chunk-store.ts
new file mode 100644 (file)
index 0000000..54cc0ea
--- /dev/null
@@ -0,0 +1,231 @@
+// From https://github.com/MinEduTDF/idb-chunk-store
+// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues
+// Thanks @santiagogil and @Feross
+
+import { EventEmitter } from 'events'
+import Dexie from 'dexie'
+
+class ChunkDatabase extends Dexie {
+  chunks: Dexie.Table<{ id: number, buf: Buffer }, number>
+
+  constructor (dbname: string) {
+    super(dbname)
+
+    this.version(1).stores({
+      chunks: 'id'
+    })
+  }
+}
+
+class ExpirationDatabase extends Dexie {
+  databases: Dexie.Table<{ name: string, expiration: number }, number>
+
+  constructor () {
+    super('webtorrent-expiration')
+
+    this.version(1).stores({
+      databases: 'name,expiration'
+    })
+  }
+}
+
+export class PeertubeChunkStore extends EventEmitter {
+  private static readonly BUFFERING_PUT_MS = 1000
+  private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute
+  private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes
+
+  chunkLength: number
+
+  private pendingPut: { id: number, buf: Buffer, cb: Function }[] = []
+  // If the store is full
+  private memoryChunks: { [ id: number ]: Buffer | true } = {}
+  private databaseName: string
+  private putBulkTimeout: any
+  private cleanerInterval: any
+  private db: ChunkDatabase
+  private expirationDB: ExpirationDatabase
+  private readonly length: number
+  private readonly lastChunkLength: number
+  private readonly lastChunkIndex: number
+
+  constructor (chunkLength: number, opts: any) {
+    super()
+
+    this.databaseName = 'webtorrent-chunks-'
+
+    if (!opts) opts = {}
+    if (opts.torrent && opts.torrent.infoHash) this.databaseName += opts.torrent.infoHash
+    else this.databaseName += '-default'
+
+    this.setMaxListeners(100)
+
+    this.chunkLength = Number(chunkLength)
+    if (!this.chunkLength) throw new Error('First argument must be a chunk length')
+
+    this.length = Number(opts.length) || Infinity
+
+    if (this.length !== Infinity) {
+      this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength
+      this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1
+    }
+
+    this.db = new ChunkDatabase(this.databaseName)
+    // Track databases that expired
+    this.expirationDB = new ExpirationDatabase()
+
+    this.runCleaner()
+  }
+
+  put (index: number, buf: Buffer, cb: (err?: Error) => void) {
+    const isLastChunk = (index === this.lastChunkIndex)
+    if (isLastChunk && buf.length !== this.lastChunkLength) {
+      return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength))
+    }
+    if (!isLastChunk && buf.length !== this.chunkLength) {
+      return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength))
+    }
+
+    // Specify we have this chunk
+    this.memoryChunks[index] = true
+
+    // Add it to the pending put
+    this.pendingPut.push({ id: index, buf, cb })
+    // If it's already planned, return
+    if (this.putBulkTimeout) return
+
+    // Plan a future bulk insert
+    this.putBulkTimeout = setTimeout(async () => {
+      const processing = this.pendingPut
+      this.pendingPut = []
+      this.putBulkTimeout = undefined
+
+      try {
+        await this.db.transaction('rw', this.db.chunks, () => {
+          return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf })))
+        })
+      } catch (err) {
+        console.log('Cannot bulk insert chunks. Store them in memory.', { err })
+
+        processing.forEach(p => this.memoryChunks[ p.id ] = p.buf)
+      } finally {
+        processing.forEach(p => p.cb())
+      }
+    }, PeertubeChunkStore.BUFFERING_PUT_MS)
+  }
+
+  get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void {
+    if (typeof opts === 'function') return this.get(index, null, opts)
+
+    // IndexDB could be slow, use our memory index first
+    const memoryChunk = this.memoryChunks[index]
+    if (memoryChunk === undefined) {
+      const err = new Error('Chunk not found') as any
+      err['notFound'] = true
+
+      return process.nextTick(() => cb(err))
+    }
+
+    // Chunk in memory
+    if (memoryChunk !== true) return cb(null, memoryChunk)
+
+    // Chunk in store
+    this.db.transaction('r', this.db.chunks, async () => {
+      const result = await this.db.chunks.get({ id: index })
+      if (result === undefined) return cb(null, new Buffer(0))
+
+      const buf = result.buf
+      if (!opts) return this.nextTick(cb, null, buf)
+
+      const offset = opts.offset || 0
+      const len = opts.length || (buf.length - offset)
+      return cb(null, buf.slice(offset, len + offset))
+    })
+    .catch(err => {
+      console.error(err)
+      return cb(err)
+    })
+  }
+
+  close (cb: (err?: Error) => void) {
+    return this.destroy(cb)
+  }
+
+  async destroy (cb: (err?: Error) => void) {
+    try {
+      if (this.pendingPut) {
+        clearTimeout(this.putBulkTimeout)
+        this.pendingPut = null
+      }
+      if (this.cleanerInterval) {
+        clearInterval(this.cleanerInterval)
+        this.cleanerInterval = null
+      }
+
+      if (this.db) {
+        await this.db.close()
+
+        await this.dropDatabase(this.databaseName)
+      }
+
+      if (this.expirationDB) {
+        await this.expirationDB.close()
+        this.expirationDB = null
+      }
+
+      return cb()
+    } catch (err) {
+      console.error('Cannot destroy peertube chunk store.', err)
+      return cb(err)
+    }
+  }
+
+  private runCleaner () {
+    this.checkExpiration()
+
+    this.cleanerInterval = setInterval(async () => {
+      this.checkExpiration()
+    }, PeertubeChunkStore.CLEANER_INTERVAL_MS)
+  }
+
+  private async checkExpiration () {
+    let databasesToDeleteInfo: { name: string }[] = []
+
+    try {
+      await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => {
+        // Update our database expiration since we are alive
+        await this.expirationDB.databases.put({
+          name: this.databaseName,
+          expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS
+        })
+
+        const now = new Date().getTime()
+        databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray()
+      })
+    } catch (err) {
+      console.error('Cannot update expiration of fetch expired databases.', err)
+    }
+
+    for (const databaseToDeleteInfo of databasesToDeleteInfo) {
+      await this.dropDatabase(databaseToDeleteInfo.name)
+    }
+  }
+
+  private async dropDatabase (databaseName: string) {
+    const dbToDelete = new ChunkDatabase(databaseName)
+    console.log('Destroying IndexDB database %s.', databaseName)
+
+    try {
+      await dbToDelete.delete()
+
+      await this.expirationDB.transaction('rw', this.expirationDB.databases, () => {
+        return this.expirationDB.databases.where({ name: databaseName }).delete()
+      })
+    } catch (err) {
+      console.error('Cannot delete %s.', databaseName, err)
+    }
+  }
+
+  private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
+    process.nextTick(() => cb(err, val), undefined)
+  }
+}
diff --git a/client/src/assets/player/webtorrent/video-renderer.ts b/client/src/assets/player/webtorrent/video-renderer.ts
new file mode 100644 (file)
index 0000000..a341593
--- /dev/null
@@ -0,0 +1,134 @@
+// Thanks: https://github.com/feross/render-media
+// TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed
+
+const MediaElementWrapper = require('mediasource')
+import { extname } from 'path'
+const videostream = require('videostream')
+
+const VIDEOSTREAM_EXTS = [
+  '.m4a',
+  '.m4v',
+  '.mp4'
+]
+
+type RenderMediaOptions = {
+  controls: boolean
+  autoplay: boolean
+}
+
+function renderVideo (
+  file: any,
+  elem: HTMLVideoElement,
+  opts: RenderMediaOptions,
+  callback: (err: Error, renderer: any) => void
+) {
+  validateFile(file)
+
+  return renderMedia(file, elem, opts, callback)
+}
+
+function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) {
+  const extension = extname(file.name).toLowerCase()
+  let preparedElem: any = undefined
+  let currentTime = 0
+  let renderer: any
+
+  try {
+    if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) {
+      renderer = useVideostream()
+    } else {
+      renderer = useMediaSource()
+    }
+  } catch (err) {
+    return callback(err)
+  }
+
+  function useVideostream () {
+    prepareElem()
+    preparedElem.addEventListener('error', function onError (err: Error) {
+      preparedElem.removeEventListener('error', onError)
+
+      return callback(err)
+    })
+    preparedElem.addEventListener('loadstart', onLoadStart)
+    return videostream(file, preparedElem)
+  }
+
+  function useMediaSource (useVP9 = false) {
+    const codecs = getCodec(file.name, useVP9)
+
+    prepareElem()
+    preparedElem.addEventListener('error', function onError (err: Error) {
+      preparedElem.removeEventListener('error', onError)
+
+      // Try with vp9 before returning an error
+      if (codecs.indexOf('vp8') !== -1) return fallbackToMediaSource(true)
+
+      return callback(err)
+    })
+    preparedElem.addEventListener('loadstart', onLoadStart)
+
+    const wrapper = new MediaElementWrapper(preparedElem)
+    const writable = wrapper.createWriteStream(codecs)
+    file.createReadStream().pipe(writable)
+
+    if (currentTime) preparedElem.currentTime = currentTime
+
+    return wrapper
+  }
+
+  function fallbackToMediaSource (useVP9 = false) {
+    if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.')
+    else console.log('Falling back to media source..')
+
+    useMediaSource(useVP9)
+  }
+
+  function prepareElem () {
+    if (preparedElem === undefined) {
+      preparedElem = elem
+
+      preparedElem.addEventListener('progress', function () {
+        currentTime = elem.currentTime
+      })
+    }
+  }
+
+  function onLoadStart () {
+    preparedElem.removeEventListener('loadstart', onLoadStart)
+    if (opts.autoplay) preparedElem.play()
+
+    callback(null, renderer)
+  }
+}
+
+function validateFile (file: any) {
+  if (file == null) {
+    throw new Error('file cannot be null or undefined')
+  }
+  if (typeof file.name !== 'string') {
+    throw new Error('missing or invalid file.name property')
+  }
+  if (typeof file.createReadStream !== 'function') {
+    throw new Error('missing or invalid file.createReadStream property')
+  }
+}
+
+function getCodec (name: string, useVP9 = false) {
+  const ext = extname(name).toLowerCase()
+  if (ext === '.mp4') {
+    return 'video/mp4; codecs="avc1.640029, mp4a.40.5"'
+  }
+
+  if (ext === '.webm') {
+    if (useVP9 === true) return 'video/webm; codecs="vp9, opus"'
+
+    return 'video/webm; codecs="vp8, vorbis"'
+  }
+
+  return undefined
+}
+
+export {
+  renderVideo
+}
index 54b8fb54300133ca09b635fde06edd335733e6b2..b1261c4a2e08d88455523a4dd3fe61852519f44b 100644 (file)
@@ -17,17 +17,13 @@ import 'core-js/es6/set'
 // For google bot that uses Chrome 41 and does not understand fetch
 import 'whatwg-fetch'
 
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as vjs from 'video.js'
-
 import * as Channel from 'jschannel'
 
 import { peertubeTranslate, ResultList, VideoDetails } from '../../../../shared'
-import { addContextMenu, getServerTranslations, getVideojsOptions, loadLocaleInVideoJS } from '../../assets/player/peertube-player'
 import { PeerTubeResolution } from '../player/definitions'
 import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
 import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
+import { PeertubePlayerManager, PeertubePlayerManagerOptions } from '../../assets/player/peertube-player-manager'
 
 /**
  * Embed API exposes control of the embed player to the outside world via
@@ -73,16 +69,16 @@ class PeerTubeEmbedApi {
   }
 
   private setResolution (resolutionId: number) {
-    if (resolutionId === -1 && this.embed.player.peertube().isAutoResolutionForbidden()) return
+    if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionForbidden()) return
 
     // Auto resolution
     if (resolutionId === -1) {
-      this.embed.player.peertube().enableAutoResolution()
+      this.embed.player.webtorrent().enableAutoResolution()
       return
     }
 
-    this.embed.player.peertube().disableAutoResolution()
-    this.embed.player.peertube().updateResolution(resolutionId)
+    this.embed.player.webtorrent().disableAutoResolution()
+    this.embed.player.webtorrent().updateResolution(resolutionId)
   }
 
   /**
@@ -122,15 +118,17 @@ class PeerTubeEmbedApi {
 
     // PeerTube specific capabilities
 
-    this.embed.player.peertube().on('autoResolutionUpdate', () => this.loadResolutions())
-    this.embed.player.peertube().on('videoFileUpdate', () => this.loadResolutions())
+    if (this.embed.player.webtorrent) {
+      this.embed.player.webtorrent().on('autoResolutionUpdate', () => this.loadWebTorrentResolutions())
+      this.embed.player.webtorrent().on('videoFileUpdate', () => this.loadWebTorrentResolutions())
+    }
   }
 
-  private loadResolutions () {
+  private loadWebTorrentResolutions () {
     let resolutions = []
-    let currentResolutionId = this.embed.player.peertube().getCurrentResolutionId()
+    let currentResolutionId = this.embed.player.webtorrent().getCurrentResolutionId()
 
-    for (const videoFile of this.embed.player.peertube().videoFiles) {
+    for (const videoFile of this.embed.player.webtorrent().videoFiles) {
       let label = videoFile.resolution.label
       if (videoFile.fps && videoFile.fps >= 50) {
         label += videoFile.fps
@@ -266,9 +264,8 @@ class PeerTubeEmbed {
     const urlParts = window.location.pathname.split('/')
     const videoId = urlParts[ urlParts.length - 1 ]
 
-    const [ , serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
-      loadLocaleInVideoJS(window.location.origin, vjs, navigator.language),
-      getServerTranslations(window.location.origin, navigator.language),
+    const [ serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
+      PeertubePlayerManager.getServerTranslations(window.location.origin, navigator.language),
       this.loadVideoInfo(videoId),
       this.loadVideoCaptions(videoId)
     ])
@@ -292,43 +289,56 @@ class PeerTubeEmbed {
 
     this.loadParams()
 
-    const videojsOptions = getVideojsOptions({
-      autoplay: this.autoplay,
-      controls: this.controls,
-      muted: this.muted,
-      loop: this.loop,
-      startTime: this.startTime,
-      subtitle: this.subtitle,
-
-      videoCaptions,
-      inactivityTimeout: 1500,
-      videoViewUrl: this.getVideoUrl(videoId) + '/views',
-      playerElement: this.videoElement,
-      videoFiles: videoInfo.files,
-      videoDuration: videoInfo.duration,
-      enableHotkeys: true,
-      peertubeLink: true,
-      poster: window.location.origin + videoInfo.previewPath,
-      theaterMode: false
-    })
+    const options: PeertubePlayerManagerOptions = {
+      common: {
+        autoplay: this.autoplay,
+        controls: this.controls,
+        muted: this.muted,
+        loop: this.loop,
+        captions: videoCaptions.length !== 0,
+        startTime: this.startTime,
+        subtitle: this.subtitle,
+
+        videoCaptions,
+        inactivityTimeout: 1500,
+        videoViewUrl: this.getVideoUrl(videoId) + '/views',
+        playerElement: this.videoElement,
+        videoDuration: videoInfo.duration,
+        enableHotkeys: true,
+        peertubeLink: true,
+        poster: window.location.origin + videoInfo.previewPath,
+        theaterMode: false,
+
+        serverUrl: window.location.origin,
+        language: navigator.language,
+        embedUrl: window.location.origin + videoInfo.embedPath
+      },
+
+      webtorrent: {
+        videoFiles: videoInfo.files
+      }
+
+      // p2pMediaLoader: {
+      //   // playlistUrl: 'https://akamai-axtest.akamaized.net/routes/lapd-v1-acceptance/www_c4/Manifest.m3u8'
+      //   // playlistUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8'
+      //   playlistUrl: 'https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'
+      // }
+    }
 
-    this.playerOptions = videojsOptions
-    this.player = vjs(this.videoContainerId, videojsOptions, () => {
-      this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
+    this.player = await PeertubePlayerManager.initialize('webtorrent', options)
 
-      window[ 'videojsPlayer' ] = this.player
+    this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
 
-      if (this.controls) {
-        this.player.dock({
-          title: videoInfo.name,
-          description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
-        })
-      }
+    window[ 'videojsPlayer' ] = this.player
 
-      addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
+    if (this.controls) {
+      this.player.dock({
+        title: videoInfo.name,
+        description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
+      })
+    }
 
-      this.initializeApi()
-    })
+    this.initializeApi()
   }
 
   private handleError (err: Error, translations?: { [ id: string ]: string }) {
index af7a74e9ecf326d68f9117385c3ad9f043052f9f..729eee35359d6c9c1de71d90ab1fd5c1e63094bf 100644 (file)
@@ -3,7 +3,7 @@
   "compilerOptions": {
     "outDir": "../out-tsc/app",
     "baseUrl": "./",
-    "module": "es2015",
+    "module": "esnext",
     "types": [],
     "lib": [
       "es2017",
index dee67c41429ebd02c2d7f5067c065cc38a61ff98..0698ca501c4654aa18a0f6e079082483cb163fa0 100644 (file)
     semver "5.5.1"
     semver-intersect "1.4.0"
 
+"@streamroot/videojs-hlsjs-plugin@^1.0.7":
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/@streamroot/videojs-hlsjs-plugin/-/videojs-hlsjs-plugin-1.0.7.tgz#581aecdf6a966162b404c60bd3ab8264eb89d334"
+  integrity sha512-7oAIOhEFxkfLOYWDfg7Oh3+OrnoTElRvUE3Jblg2B+SHmnrw4YXQnAwYJ0AHjNIBKoHnQubzZGttLaHAFJVspQ==
+
 "@types/bittorrent-protocol@*":
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/@types/bittorrent-protocol/-/bittorrent-protocol-2.2.2.tgz#169e9633e1bd18e6b830d11cf42e611b1972cb83"
@@ -1445,7 +1450,7 @@ bittorrent-protocol@^3.0.0:
     unordered-array-remove "^1.0.2"
     xtend "^4.0.0"
 
-bittorrent-tracker@^9.0.0:
+bittorrent-tracker@^9.0.0, bittorrent-tracker@^9.10.1:
   version "9.10.1"
   resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.10.1.tgz#5de14aac012a287af394d3cc9eda1ec6cc956f11"
   integrity sha512-n5zTL/g6Wt0rb2EnkiyiaGYhth7I/N0/xMqGUpvGX/7g1scDGBVPhJnXR8lfp3/OMj681fv40o4q/otECMtZSA==
@@ -3305,6 +3310,11 @@ events@^1.0.0:
   resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
   integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
 
+events@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
+  integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==
+
 eventsource@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0"
@@ -3900,7 +3910,7 @@ genfun@^5.0.0:
   resolved "https://registry.yarnpkg.com/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537"
   integrity sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA==
 
-get-browser-rtc@^1.0.0:
+get-browser-rtc@^1.0.0, get-browser-rtc@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz#bbcd40c8451a7ed4ef5c373b8169a409dd1d11d9"
   integrity sha1-u81AyEUaftTvXDc7gWmkCd0dEdk=
@@ -6108,6 +6118,13 @@ m3u8-parser@4.2.0:
   resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.2.0.tgz#c8e0785fd17f741f4408b49466889274a9e36447"
   integrity sha512-LVHw0U6IPJjwk9i9f7Xe26NqaUHTNlIt4SSWoEfYFROeVKHN6MIjOhbRheI3dg8Jbq5WCuMFQ0QU3EgZpmzFPg==
 
+m3u8-parser@^4.2.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.3.0.tgz#4b4e988f87b6d8b2401d209a1d17798285a9da04"
+  integrity sha512-bVbjuBMoVIgFL1vpXVIxjeaoB5TPDJRb0m5qiTdM738SGqv/LAmsnVVPlKjM4fulm/rr1XZsKM+owHm+zvqxYA==
+  dependencies:
+    global "^4.3.2"
+
 magic-string@^0.25.0:
   version "0.25.1"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.1.tgz#b1c248b399cd7485da0fe7385c2fc7011843266e"
@@ -7214,6 +7231,26 @@ p-try@^2.0.0:
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
   integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==
 
+p2p-media-loader-core@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/p2p-media-loader-core/-/p2p-media-loader-core-0.3.0.tgz#75687d7d7bee835d5c6c2f17d346add2dbe43b83"
+  integrity sha512-WKB9ONdA0kDQHXr6nixIL8t0UZuTD9Pqi/BIuaTiPUGDwYXUS/Mf5YynLAUupniLkIaDYD7/jmSLWqpZUDsAyw==
+  dependencies:
+    bittorrent-tracker "^9.10.1"
+    debug "^4.1.0"
+    events "^3.0.0"
+    get-browser-rtc "^1.0.2"
+    sha.js "^2.4.11"
+
+p2p-media-loader-hlsjs@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-0.3.0.tgz#4ee15d4d1a23aa0322a5be2bc6c329b6c913028d"
+  integrity sha512-U7PzMG5X7CVQ15OtMPRQjW68Msu0fuw8Pp0PRznX5uK0p26tSYMT/ZYCNeYCoDg3wGgJHM+327ed3M7TRJ4lcw==
+  dependencies:
+    events "^3.0.0"
+    m3u8-parser "^4.2.0"
+    p2p-media-loader-core "^0.3.0"
+
 package-json-versionify@^1.0.2:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/package-json-versionify/-/package-json-versionify-1.0.4.tgz#5860587a944873a6b7e6d26e8e51ffb22315bf17"
@@ -8699,7 +8736,7 @@ setprototypeof@1.1.0:
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
   integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
 
-sha.js@^2.4.0, sha.js@^2.4.8:
+sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8:
   version "2.4.11"
   resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7"
   integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==
@@ -10090,6 +10127,14 @@ videojs-contextmenu-ui@^5.0.0:
     global "^4.3.2"
     video.js "^6 || ^7"
 
+videojs-contrib-quality-levels@^2.0.9:
+  version "2.0.9"
+  resolved "https://registry.yarnpkg.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-2.0.9.tgz#b5d533d5092a6fc7d29eae1b43e4597d89bd527b"
+  integrity sha512-HJeaJJQdSufi9Y5T7jlyyhkeq+mWPCog86q6ypoTi66boBMMJTo2abiOSHS9KaOGAJjH72gfvrjVY5FRdjlxYA==
+  dependencies:
+    global "^4.3.2"
+    video.js "^6 || ^7"
+
 videojs-dock@^2.0.2:
   version "2.1.4"
   resolved "https://registry.yarnpkg.com/videojs-dock/-/videojs-dock-2.1.4.tgz#0ebd198b5d48990e3523fdc87dbfdb9fe96f804c"
index 9b8fddac6595c2c68a225a65e415bad0315f4781..b4675c57f54c3d8b3a4f609e9382f40d9291d079 100755 (executable)
@@ -4,7 +4,7 @@ set -eu
 
 if [ ! -f "./client/dist/en_US/index.html" ]; then
   echo "client/dist/en_US/index.html does not exist, compile client files..."
-  npm run build:client
+  npm run build:client -- --light
 fi
 
 npm run watch:server
index 8b919af0d1ead6e4329af09f0bba58fc886fbeba..5fa9d1ab547d13c9a00bc3cea9179b25a7057ed7 100644 (file)
@@ -16,7 +16,7 @@ const baseDirectives = Object.assign({},
     baseUri: ["'self'"],
     manifestSrc: ["'self'"],
     frameSrc: ["'self'"], // instead of deprecated child-src / self because of test-embed
-    workerSrc: ["'self'"] // instead of deprecated child-src
+    workerSrc: ["'self'", 'blob:'] // instead of deprecated child-src
   },
   CONFIG.SERVICES['CSP-LOGGER'] ? { reportUri: CONFIG.SERVICES['CSP-LOGGER'] } : {},
   CONFIG.WEBSERVER.SCHEME === 'https' ? { upgradeInsecureRequests: true } : {}