]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Improve player
authorChocobozzz <me@florianbigard.com>
Fri, 30 Mar 2018 15:40:00 +0000 (17:40 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 3 Apr 2018 12:02:10 +0000 (14:02 +0200)
Add a settings dialog based on the work of Yanko Shterev (@yshterev):
https://github.com/yshterev/videojs-settings-menu. Thanks!

17 files changed:
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/assets/player/images/settings.svg [new file with mode: 0644]
client/src/assets/player/images/tick.svg [new file with mode: 0644]
client/src/assets/player/peertube-link-button.ts [new file with mode: 0644]
client/src/assets/player/peertube-player.ts [new file with mode: 0644]
client/src/assets/player/peertube-videojs-plugin.ts
client/src/assets/player/peertube-videojs-typings.ts [new file with mode: 0644]
client/src/assets/player/resolution-menu-button.ts [new file with mode: 0644]
client/src/assets/player/resolution-menu-item.ts [new file with mode: 0644]
client/src/assets/player/settings-menu-button.ts [new file with mode: 0644]
client/src/assets/player/settings-menu-item.ts [new file with mode: 0644]
client/src/assets/player/utils.ts [new file with mode: 0644]
client/src/assets/player/webtorrent-info-button.ts [new file with mode: 0644]
client/src/sass/include/_mixins.scss
client/src/sass/video-js-custom.scss
client/src/standalone/videos/embed.scss
client/src/standalone/videos/embed.ts

index fda69efab6ff40afbb04ea1f254a0592036f8768..c7e26fad29aecd50fa876666d3f3919bdb8ae51d 100644 (file)
@@ -21,6 +21,7 @@ import { MarkdownService } from '../shared'
 import { VideoDownloadComponent } from './modal/video-download.component'
 import { VideoReportComponent } from './modal/video-report.component'
 import { VideoShareComponent } from './modal/video-share.component'
+import { getVideojsOptions } from '../../../assets/player/peertube-player'
 
 @Component({
   selector: 'my-video-watch',
@@ -341,45 +342,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         this.playerElement.poster = this.video.previewUrl
       }
 
-      const videojsOptions = {
-        controls: true,
+      const videojsOptions = getVideojsOptions({
         autoplay: this.isAutoplay(),
-        playbackRates: [ 0.5, 1, 1.5, 2 ],
-        plugins: {
-          peertube: {
-            videoFiles: this.video.files,
-            playerElement: this.playerElement,
-            videoViewUrl: this.videoService.getVideoViewUrl(this.video.uuid),
-            videoDuration: this.video.duration
-          },
-          hotkeys: {
-            enableVolumeScroll: false
-          }
-        },
-        controlBar: {
-          children: [
-            'playToggle',
-            'currentTimeDisplay',
-            'timeDivider',
-            'durationDisplay',
-            'liveDisplay',
-
-            'flexibleWidthSpacer',
-            'progressControl',
-
-            'webTorrentButton',
-
-            'playbackRateMenuButton',
-
-            'muteToggle',
-            'volumeControl',
-
-            'resolutionMenuButton',
-
-            'fullscreenToggle'
-          ]
-        }
-      }
+        inactivityTimeout: 4000,
+        videoFiles: this.video.files,
+        playerElement: this.playerElement,
+        videoViewUrl: this.videoService.getVideoViewUrl(this.video.uuid),
+        videoDuration: this.video.duration,
+        enableHotkeys: true,
+        peertubeLink: false
+      })
 
       this.videoPlayerLoaded = true
 
diff --git a/client/src/assets/player/images/settings.svg b/client/src/assets/player/images/settings.svg
new file mode 100644 (file)
index 0000000..c663087
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+    <title>settings</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
+        <g id="Artboard-4" transform="translate(-796.000000, -159.000000)" stroke="#fff" stroke-width="2">
+            <g id="38" transform="translate(796.000000, 159.000000)">
+                <path d="M7.20852293,4.3800958 C8.05442158,3.84706631 8.99528987,3.45099725 10,3.22301642 L10,1.99980749 C10,1.44762906 10.4433532,1 11.0093689,1 L12.9906311,1 C13.5480902,1 14,1.44371665 14,1.99980749 L14,3.22301642 C15.0047101,3.45099725 15.9455784,3.84706631 16.7914771,4.3800958 L17.6569904,3.5145825 C18.0474395,3.12413339 18.6774591,3.12110988 19.0776926,3.52134344 L20.4786566,4.92230738 C20.8728396,5.31649045 20.8786331,5.94979402 20.4854175,6.34300963 L19.6199042,7.20852293 C20.1529337,8.05442158 20.5490027,8.99528987 20.7769836,10 L22.0001925,10 C22.5523709,10 23,10.4433532 23,11.0093689 L23,12.9906311 C23,13.5480902 22.5562834,14 22.0001925,14 L20.7769836,14 C20.5490027,15.0047101 20.1529337,15.9455784 19.6199042,16.7914771 L20.4854175,17.6569904 C20.8758666,18.0474395 20.8788901,18.6774591 20.4786566,19.0776926 L19.0776926,20.4786566 C18.6835095,20.8728396 18.050206,20.8786331 17.6569904,20.4854175 L16.7914771,19.6199042 C15.9455784,20.1529337 15.0047101,20.5490027 14,20.7769836 L14,22.0001925 C14,22.5523709 13.5566468,23 12.9906311,23 L11.0093689,23 C10.4519098,23 10,22.5562834 10,22.0001925 L10,20.7769836 C8.99528987,20.5490027 8.05442158,20.1529337 7.20852293,19.6199042 L6.34300963,20.4854175 C5.95256051,20.8758666 5.32254093,20.8788901 4.92230738,20.4786566 L3.52134344,19.0776926 C3.12716036,18.6835095 3.12136689,18.050206 3.5145825,17.6569904 L4.3800958,16.7914771 C3.84706631,15.9455784 3.45099725,15.0047101 3.22301642,14 L1.99980749,14 C1.44762906,14 1,13.5566468 1,12.9906311 L1,11.0093689 C1,10.4519098 1.44371665,10 1.99980749,10 L3.22301642,10 C3.45099725,8.99528987 3.84706631,8.05442158 4.3800958,7.20852293 L3.5145825,6.34300963 C3.12413339,5.95256051 3.12110988,5.32254093 3.52134344,4.92230738 L4.92230738,3.52134344 C5.31649045,3.12716036 5.94979402,3.12136689 6.34300963,3.5145825 L7.20852293,4.3800958 Z M12,16 C14.209139,16 16,14.209139 16,12 C16,9.790861 14.209139,8 12,8 C9.790861,8 8,9.790861 8,12 C8,14.209139 9.790861,16 12,16 Z" id="Combined-Shape"></path>
+            </g>
+        </g>
+    </g>
+</svg>
diff --git a/client/src/assets/player/images/tick.svg b/client/src/assets/player/images/tick.svg
new file mode 100644 (file)
index 0000000..d329e6b
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
+        <g id="Artboard-4" transform="translate(-356.000000, -115.000000)" stroke="#fff" stroke-width="2">
+            <g id="8" transform="translate(356.000000, 115.000000)">
+                <path d="M21,6 L9,18" id="Path-14"></path>
+                <path d="M9,13 L4,18" id="Path-14" transform="translate(6.500000, 15.500000) scale(-1, 1) translate(-6.500000, -15.500000) "></path>
+            </g>
+        </g>
+    </g>
+</svg>
diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts
new file mode 100644 (file)
index 0000000..6ead78c
--- /dev/null
@@ -0,0 +1,20 @@
+import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
+
+const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
+class PeerTubeLinkButton extends Button {
+
+  createEl () {
+    return videojsUntyped.dom.createEl('a', {
+      href: window.location.href.replace('embed', 'watch'),
+      innerHTML: 'PeerTube',
+      title: 'Go to the video page',
+      className: 'vjs-peertube-link',
+      target: '_blank'
+    })
+  }
+
+  handleClick () {
+    this.player_.pause()
+  }
+}
+Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton)
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
new file mode 100644 (file)
index 0000000..4ae3e71
--- /dev/null
@@ -0,0 +1,96 @@
+import { VideoFile } from '../../../../shared/models/videos'
+
+import 'videojs-hotkeys'
+import 'videojs-dock/dist/videojs-dock.es.js'
+import './peertube-link-button'
+import './resolution-menu-button'
+import './settings-menu-button'
+import './webtorrent-info-button'
+import './peertube-videojs-plugin'
+import { videojsUntyped } from './peertube-videojs-typings'
+
+// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
+videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
+
+function getVideojsOptions (options: {
+  autoplay: boolean,
+  playerElement: HTMLVideoElement,
+  videoViewUrl: string,
+  videoDuration: number,
+  videoFiles: VideoFile[],
+  enableHotkeys: boolean,
+  inactivityTimeout: number,
+  peertubeLink: boolean
+}) {
+  const videojsOptions = {
+    controls: true,
+    autoplay: options.autoplay,
+    inactivityTimeout: options.inactivityTimeout,
+    playbackRates: [ 0.5, 1, 1.5, 2 ],
+    plugins: {
+      peertube: {
+        videoFiles: options.videoFiles,
+        playerElement: options.playerElement,
+        videoViewUrl: options.videoViewUrl,
+        videoDuration: options.videoDuration
+      }
+    },
+    controlBar: {
+      children: getControlBarChildren(options)
+    }
+  }
+
+  if (options.enableHotkeys === true) {
+    Object.assign(videojsOptions.plugins, {
+      hotkeys: {
+        enableVolumeScroll: false
+      }
+    })
+  }
+
+  return videojsOptions
+}
+
+function getControlBarChildren (options: {
+  peertubeLink: boolean
+}) {
+  const children = {
+    'playToggle': {},
+    'currentTimeDisplay': {},
+    'timeDivider': {},
+    'durationDisplay': {},
+    'liveDisplay': {},
+
+    'flexibleWidthSpacer': {},
+    'progressControl': {},
+
+    'webTorrentButton': {},
+
+    'muteToggle': {},
+    'volumeControl': {},
+
+    'settingsButton': {
+      setup: {
+        maxHeightOffset: 40
+      },
+      entries: [
+        'resolutionMenuButton',
+        'playbackRateMenuButton'
+      ]
+    }
+  }
+
+  if (options.peertubeLink === true) {
+    Object.assign(children, {
+      'peerTubeLinkButton': {}
+    })
+  }
+
+  Object.assign(children, {
+    'fullscreenToggle': {}
+  })
+
+  return children
+}
+
+export { getVideojsOptions }
index 22cb27da3e4181b9c97f0d41fef34828e4de8013..c35ce12cb038262790fa056785e760facf26d67a 100644 (file)
@@ -1,49 +1,11 @@
-// Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher
-
 import * as videojs from 'video.js'
 import * as WebTorrent from 'webtorrent'
-import { VideoConstant, VideoResolution } from '../../../../shared/models/videos'
 import { VideoFile } from '../../../../shared/models/videos/video.model'
 import { renderVideo } from './video-renderer'
+import './settings-menu-button'
+import { PeertubePluginOptions, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
+import { getStoredMute, getStoredVolume, saveMuteInStore, saveVolumeInStore } from './utils'
 
-declare module 'video.js' {
-  interface Player {
-    peertube (): PeerTubePlugin
-  }
-}
-
-interface VideoJSComponentInterface {
-  _player: videojs.Player
-
-  new (player: videojs.Player, options?: any)
-
-  registerComponent (name: string, obj: any)
-}
-
-type PeertubePluginOptions = {
-  videoFiles: VideoFile[]
-  playerElement: HTMLVideoElement
-  videoViewUrl: string
-  videoDuration: number
-}
-
-// https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
-// Don't import all Angular stuff, just copy the code with shame
-const dictionaryBytes: Array<{max: number, type: string}> = [
-  { max: 1024, type: 'B' },
-  { max: 1048576, type: 'KB' },
-  { max: 1073741824, type: 'MB' },
-  { max: 1.0995116e12, type: 'GB' }
-]
-function bytes (value) {
-  const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1]
-  const calc = Math.floor(value / (format.max / 1024)).toString()
-
-  return [ calc, format.type ]
-}
-
-// videojs typings don't have some method we need
-const videojsUntyped = videojs as any
 const webtorrent = new WebTorrent({
   tracker: {
     rtcConfig: {
@@ -60,199 +22,19 @@ const webtorrent = new WebTorrent({
   dht: false
 })
 
-const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
-class ResolutionMenuItem extends MenuItem {
-
-  constructor (player: videojs.Player, options) {
-    options.selectable = true
-    super(player, options)
-
-    const currentResolutionId = this.player_.peertube().getCurrentResolutionId()
-    this.selected(this.options_.id === currentResolutionId)
-  }
-
-  handleClick (event) {
-    super.handleClick(event)
-
-    this.player_.peertube().updateResolution(this.options_.id)
-  }
-}
-MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
-
-const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton')
-class ResolutionMenuButton extends MenuButton {
-  label: HTMLElement
-
-  constructor (player: videojs.Player, options) {
-    options.label = 'Quality'
-    super(player, options)
-
-    this.label = document.createElement('span')
-
-    this.el().setAttribute('aria-label', 'Quality')
-    this.controlText('Quality')
-
-    videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label')
-    this.el().appendChild(this.label)
-
-    player.peertube().on('videoFileUpdate', () => this.update())
-  }
-
-  createItems () {
-    const menuItems = []
-    for (const videoFile of this.player_.peertube().videoFiles) {
-      menuItems.push(new ResolutionMenuItem(
-        this.player_,
-        {
-          id: videoFile.resolution.id,
-          label: videoFile.resolution.label,
-          src: videoFile.magnetUri,
-          selected: videoFile.resolution.id === this.currentSelectionId
-        })
-      )
-    }
-
-    return menuItems
-  }
-
-  update () {
-    if (!this.label) return
-
-    this.label.innerHTML = this.player_.peertube().getCurrentResolutionLabel()
-    this.hide()
-    return super.update()
-  }
-
-  buildCSSClass () {
-    return super.buildCSSClass() + ' vjs-resolution-button'
-  }
-
-  buildWrapperCSSClass () {
-    return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
-  }
-}
-MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
-
-const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
-class PeerTubeLinkButton extends Button {
-
-  createEl () {
-    const link = document.createElement('a')
-    link.href = window.location.href.replace('embed', 'watch')
-    link.innerHTML = 'PeerTube'
-    link.title = 'Go to the video page'
-    link.className = 'vjs-peertube-link'
-    link.target = '_blank'
-
-    return link
-  }
-
-  handleClick () {
-    this.player_.pause()
-  }
-}
-Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton)
-
-class WebTorrentButton extends Button {
-  createEl () {
-    const div = document.createElement('div')
-    const subDivWebtorrent = document.createElement('div')
-    div.appendChild(subDivWebtorrent)
-
-    const downloadIcon = document.createElement('span')
-    downloadIcon.classList.add('icon', 'icon-download')
-    subDivWebtorrent.appendChild(downloadIcon)
-
-    const downloadSpeedText = document.createElement('span')
-    downloadSpeedText.classList.add('download-speed-text')
-    const downloadSpeedNumber = document.createElement('span')
-    downloadSpeedNumber.classList.add('download-speed-number')
-    const downloadSpeedUnit = document.createElement('span')
-    downloadSpeedText.appendChild(downloadSpeedNumber)
-    downloadSpeedText.appendChild(downloadSpeedUnit)
-    subDivWebtorrent.appendChild(downloadSpeedText)
-
-    const uploadIcon = document.createElement('span')
-    uploadIcon.classList.add('icon', 'icon-upload')
-    subDivWebtorrent.appendChild(uploadIcon)
-
-    const uploadSpeedText = document.createElement('span')
-    uploadSpeedText.classList.add('upload-speed-text')
-    const uploadSpeedNumber = document.createElement('span')
-    uploadSpeedNumber.classList.add('upload-speed-number')
-    const uploadSpeedUnit = document.createElement('span')
-    uploadSpeedText.appendChild(uploadSpeedNumber)
-    uploadSpeedText.appendChild(uploadSpeedUnit)
-    subDivWebtorrent.appendChild(uploadSpeedText)
-
-    const peersText = document.createElement('span')
-    peersText.classList.add('peers-text')
-    const peersNumber = document.createElement('span')
-    peersNumber.classList.add('peers-number')
-    subDivWebtorrent.appendChild(peersNumber)
-    subDivWebtorrent.appendChild(peersText)
-
-    div.className = 'vjs-peertube'
-    // Hide the stats before we get the info
-    subDivWebtorrent.className = 'vjs-peertube-hidden'
-
-    const subDivHttp = document.createElement('div')
-    subDivHttp.className = 'vjs-peertube-hidden'
-    const subDivHttpText = document.createElement('span')
-    subDivHttpText.classList.add('peers-number')
-    subDivHttpText.textContent = 'HTTP'
-    const subDivFallbackText = document.createElement('span')
-    subDivFallbackText.classList.add('peers-text')
-    subDivFallbackText.textContent = ' fallback'
-
-    subDivHttp.appendChild(subDivHttpText)
-    subDivHttp.appendChild(subDivFallbackText)
-    div.appendChild(subDivHttp)
-
-    this.player_.peertube().on('torrentInfo', (event, data) => {
-      // 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 numPeers = data.numPeers
-
-      downloadSpeedNumber.textContent = downloadSpeed[ 0 ]
-      downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ]
-
-      uploadSpeedNumber.textContent = uploadSpeed[ 0 ]
-      uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
-
-      peersNumber.textContent = numPeers
-      peersText.textContent = ' peers'
-
-      subDivHttp.className = 'vjs-peertube-hidden'
-      subDivWebtorrent.className = 'vjs-peertube-displayed'
-    })
-
-    return div
-  }
-}
-Button.registerComponent('WebTorrentButton', WebTorrentButton)
-
 const Plugin: VideoJSComponentInterface = videojsUntyped.getPlugin('plugin')
 class PeerTubePlugin extends Plugin {
+  private readonly playerElement: HTMLVideoElement
+  private readonly autoplay: boolean = false
+  private readonly savePlayerSrcFunction: Function
   private player: any
   private currentVideoFile: VideoFile
-  private playerElement: HTMLVideoElement
   private videoFiles: VideoFile[]
   private torrent: WebTorrent.Torrent
-  private autoplay = false
   private videoViewUrl: string
   private videoDuration: number
   private videoViewInterval
   private torrentInfoInterval
-  private savePlayerSrcFunction: Function
 
   constructor (player: videojs.Player, options: PeertubePluginOptions) {
     super(player, options)
@@ -274,10 +56,20 @@ class PeerTubePlugin extends Plugin {
     this.playerElement = options.playerElement
 
     this.player.ready(() => {
+      const volume = getStoredVolume()
+      if (volume !== undefined) this.player.volume(volume)
+      const muted = getStoredMute()
+      if (muted !== undefined) this.player.muted(muted)
+
       this.initializePlayer()
       this.runTorrentInfoScheduler()
       this.runViewAdd()
     })
+
+    this.player.on('volumechange', () => {
+      saveVolumeInStore(this.player.volume())
+      saveMuteInStore(this.player.muted())
+    })
   }
 
   dispose () {
@@ -311,16 +103,19 @@ class PeerTubePlugin extends Plugin {
       return
     }
 
-    // Do not display error to user because we will have multiple fallbacks
+    // Do not display error to user because we will have multiple fallback
     this.disableErrorDisplay()
 
     this.player.src = () => true
-    this.player.playbackRate(1)
+    const oldPlaybackRate = this.player.playbackRate()
 
     const previousVideoFile = this.currentVideoFile
     this.currentVideoFile = videoFile
 
-    this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, done)
+    this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, () => {
+      this.player.playbackRate(oldPlaybackRate)
+      return done()
+    })
 
     this.trigger('videoFileUpdate')
   }
@@ -337,7 +132,7 @@ class PeerTubePlugin extends Plugin {
       renderVideo(torrent.files[0], this.playerElement, options,(err, renderer) => {
         this.renderer = renderer
 
-        if (err) return this.fallbackToHttp()
+        if (err) return this.fallbackToHttp(done)
 
         if (!this.player.paused()) {
           const playPromise = this.player.play()
@@ -414,13 +209,17 @@ class PeerTubePlugin extends Plugin {
   private initializePlayer () {
     this.initSmoothProgressBar()
 
+    this.alterInactivity()
+
     if (this.autoplay === true) {
       this.updateVideoFile(undefined, () => this.player.play())
     } else {
-      this.player.one('play', () => {
-        this.player.pause()
-        this.updateVideoFile(undefined, () => this.player.play())
-      })
+      // Proxify first play
+      const oldPlay = this.player.play.bind(this.player)
+      this.player.play = () => {
+        this.updateVideoFile(undefined, () => oldPlay)
+        this.player.play = oldPlay
+      }
     }
   }
 
@@ -473,7 +272,7 @@ class PeerTubePlugin extends Plugin {
     return fetch(this.videoViewUrl, { method: 'POST' })
   }
 
-  private fallbackToHttp () {
+  private fallbackToHttp (done: Function) {
     this.flushVideoFile(this.currentVideoFile, true)
     this.torrent = null
 
@@ -484,6 +283,8 @@ class PeerTubePlugin extends Plugin {
     this.player.src = this.savePlayerSrcFunction
     this.player.src(httpUrl)
     this.player.play()
+
+    return done()
   }
 
   private handleError (err: Error | string) {
@@ -498,6 +299,25 @@ class PeerTubePlugin extends Plugin {
     this.player.removeClass('vjs-error-display-enabled')
   }
 
+  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 => c.name_ === 'SettingsDialog')
+
+    this.player.controlBar.on('mouseenter', () => disableInactivity())
+    settingsDialog.on('mouseenter', () => disableInactivity())
+    this.player.controlBar.on('mouseleave', () => enableInactivity())
+    settingsDialog.on('mouseleave', () => enableInactivity())
+  }
+
   // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
   private initSmoothProgressBar () {
     const SeekBar = videojsUntyped.getComponent('SeekBar')
@@ -520,4 +340,6 @@ class PeerTubePlugin extends Plugin {
     }
   }
 }
+
 videojsUntyped.registerPlugin('peertube', PeerTubePlugin)
+export { PeerTubePlugin }
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts
new file mode 100644 (file)
index 0000000..a58fa65
--- /dev/null
@@ -0,0 +1,33 @@
+import * as videojs from 'video.js'
+import { VideoFile } from '../../../../shared/models/videos/video.model'
+import { PeerTubePlugin } from './peertube-videojs-plugin'
+
+declare module 'video.js' {
+  interface Player {
+    peertube (): PeerTubePlugin
+  }
+}
+
+interface VideoJSComponentInterface {
+  _player: videojs.Player
+
+  new (player: videojs.Player, options?: any)
+
+  registerComponent (name: string, obj: any)
+}
+
+type PeertubePluginOptions = {
+  videoFiles: VideoFile[]
+  playerElement: HTMLVideoElement
+  videoViewUrl: string
+  videoDuration: number
+}
+
+// videojs typings don't have some method we need
+const videojsUntyped = videojs as any
+
+export {
+  VideoJSComponentInterface,
+  PeertubePluginOptions,
+  videojsUntyped
+}
diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts
new file mode 100644 (file)
index 0000000..c927b08
--- /dev/null
@@ -0,0 +1,68 @@
+import * as videojs 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: videojs.Player, options) {
+    options.label = 'Quality'
+    super(player, options)
+
+    this.controlText_ = 'Quality'
+    this.player = player
+
+    player.peertube().on('videoFileUpdate', () => this.updateLabel())
+  }
+
+  createEl () {
+    const el = super.createEl()
+
+    this.labelEl_ = videojsUntyped.dom.createEl('div', {
+      className: 'vjs-resolution-value',
+      innerHTML: this.player_.peertube().getCurrentResolutionLabel()
+    })
+
+    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) {
+      menu.addChild(new ResolutionMenuItem(
+        this.player_,
+        {
+          id: videoFile.resolution.id,
+          label: videoFile.resolution.label,
+          src: videoFile.magnetUri
+        })
+      )
+    }
+
+    return menu
+  }
+
+  updateLabel () {
+    if (!this.labelEl_) return
+
+    this.labelEl_.innerHTML = this.player_.peertube().getCurrentResolutionLabel()
+  }
+
+  buildCSSClass () {
+    return super.buildCSSClass() + ' vjs-resolution-button'
+  }
+
+  buildWrapperCSSClass () {
+    return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
+  }
+}
+MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts
new file mode 100644 (file)
index 0000000..95e0ed1
--- /dev/null
@@ -0,0 +1,31 @@
+import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
+
+const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
+class ResolutionMenuItem extends MenuItem {
+
+  constructor (player: videojs.Player, options) {
+    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.update())
+  }
+
+  handleClick (event) {
+    super.handleClick(event)
+
+    this.player_.peertube().updateResolution(this.id)
+  }
+
+  update () {
+    this.selected(this.player_.peertube().getCurrentResolutionId() === this.id)
+  }
+}
+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
new file mode 100644 (file)
index 0000000..c48e138
--- /dev/null
@@ -0,0 +1,285 @@
+// Author: Yanko Shterev
+// Thanks https://github.com/yshterev/videojs-settings-menu
+
+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) {
+    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 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, 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, data) {
+    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) {
+    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, options) {
+    const openSubMenu = function () {
+      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) {
+    super(player, options)
+  }
+
+  createEl () {
+    return super.createEl('div', {
+      className: 'vjs-settings-panel',
+      innerHTML: '',
+      tabIndex: -1
+    })
+  }
+}
+
+class SettingsPanelChild extends Component {
+  constructor (player: videojs.Player, options) {
+    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) {
+    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 Button'
+
+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
new file mode 100644 (file)
index 0000000..e979ae0
--- /dev/null
@@ -0,0 +1,313 @@
+// Author: Yanko Shterev
+// Thanks https://github.com/yshterev/videojs-settings-menu
+
+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, 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)
+
+    this.eventHandlers()
+
+    player.ready(() => {
+      this.build()
+      this.reset()
+    })
+  }
+
+  eventHandlers () {
+    this.submenuClickHandler = this.onSubmenuClick.bind(this)
+    this.transitionEndHandler = this.onTransitionEnd.bind(this)
+  }
+
+  onSubmenuClick (event) {
+    let target = null
+
+    if (event.type === 'tap') {
+      target = event.target
+    } else {
+      target = event.currentTarget
+    }
+
+    if (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.subMenu.controlText_
+  }
+
+  /**
+   * Add/remove prefixed event listener for CSS Transition
+   *
+   * @method PrefixedEvent
+   */
+  PrefixedEvent (element, type, callback, 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) {
+    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.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?: Event) {
+    let target = 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
+        }
+
+        switch (subMenu) {
+          case 'SubtitlesButton':
+          case 'CaptionsButton':
+            // subtitlesButton entering default check twice and overwriting
+            // selected label in main manu
+            if (subMenuItem.hasClass('vjs-selected')) {
+              this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label
+            }
+            break
+
+          default:
+            // Set submenu value based on what item is selected
+            if (subMenuItem.options_.selected || subMenuItem.hasClass('vjs-selected')) {
+              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/utils.ts b/client/src/assets/player/utils.ts
new file mode 100644 (file)
index 0000000..7a99dba
--- /dev/null
@@ -0,0 +1,72 @@
+function toTitleCase (str: string) {
+  return str.charAt(0).toUpperCase() + str.slice(1)
+}
+
+// https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
+// Don't import all Angular stuff, just copy the code with shame
+const dictionaryBytes: Array<{max: number, type: string}> = [
+  { max: 1024, type: 'B' },
+  { max: 1048576, type: 'KB' },
+  { max: 1073741824, type: 'MB' },
+  { max: 1.0995116e12, type: 'GB' }
+]
+function bytes (value) {
+  const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1]
+  const calc = Math.floor(value / (format.max / 1024)).toString()
+
+  return [ calc, format.type ]
+}
+
+function getStoredVolume () {
+  const value = getLocalStorage('volume')
+  if (value !== null && value !== undefined) {
+    const valueNumber = parseFloat(value)
+    if (isNaN(valueNumber)) return undefined
+
+    return valueNumber
+  }
+
+  return undefined
+}
+
+function getStoredMute () {
+  const value = getLocalStorage('mute')
+  if (value !== null && value !== undefined) return value === 'true'
+
+  return undefined
+}
+
+function saveVolumeInStore (value: number) {
+  return setLocalStorage('volume', value.toString())
+}
+
+function saveMuteInStore (value: boolean) {
+  return setLocalStorage('mute', value.toString())
+}
+
+export {
+  toTitleCase,
+  getStoredVolume,
+  saveVolumeInStore,
+  saveMuteInStore,
+  getStoredMute,
+  bytes
+}
+
+// ---------------------------------------------------------------------------
+
+const KEY_PREFIX = 'peertube-videojs-'
+
+function getLocalStorage (key: string) {
+  try {
+    return localStorage.getItem(KEY_PREFIX + key)
+  } catch {
+    return undefined
+  }
+}
+
+function setLocalStorage (key: string, value: string) {
+  try {
+    localStorage.setItem(KEY_PREFIX + key, value)
+  } catch { /* empty */ }
+}
diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts
new file mode 100644 (file)
index 0000000..8a79e0e
--- /dev/null
@@ -0,0 +1,101 @@
+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: 'peers-number',
+      textContent: 'HTTP'
+    })
+    const subDivFallbackText = videojsUntyped.dom.createEl('span', {
+      className: 'peers-text',
+      textContent: 'fallback'
+    })
+
+    subDivHttp.appendChild(subDivHttpText)
+    subDivHttp.appendChild(subDivFallbackText)
+    div.appendChild(subDivHttp)
+
+    this.player_.peertube().on('torrentInfo', (event, data) => {
+      // 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 numPeers = data.numPeers
+
+      downloadSpeedNumber.textContent = downloadSpeed[ 0 ]
+      downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ]
+
+      uploadSpeedNumber.textContent = uploadSpeed[ 0 ]
+      uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
+
+      peersNumber.textContent = numPeers
+      peersText.textContent = ' peers'
+
+      subDivHttp.className = 'vjs-peertube-hidden'
+      subDivWebtorrent.className = 'vjs-peertube-displayed'
+    })
+
+    return div
+  }
+}
+Button.registerComponent('WebTorrentButton', WebtorrentInfoButton)
index e1b1bb32c7203c3709bb15cd03c507ad1f27545b..f905f9ae5f7e4d4f7d9207b6c0f78e13642a207a 100644 (file)
   width: $size;
   height: $size;
 }
+
+@mixin chevron ($size, $border-width) {
+  border-style: solid;
+  border-width: $border-width $border-width 0 0;
+  content: '';
+  display: inline-block;
+  transform: rotate(-45deg);
+  height: $size;
+  width: $size;
+}
+
+@mixin chevron-right ($size, $border-width) {
+  @include chevron($size, $border-width);
+
+  left: 0;
+  transform: rotate(45deg);
+}
+
+@mixin chevron-left ($size, $border-width) {
+  @include chevron($size, $border-width);
+
+  left: 0.25em;
+  transform: rotate(-135deg);
+}
index 2fa3527a80a86eee241567ac3176d89adbbe7bcf..2c589553c4df9f6a472815234e5b0c9f161ed0ec 100644 (file)
@@ -1,7 +1,7 @@
 @import '_variables';
 @import '_mixins';
 
-$primary-foreground-color: #eee;
+$primary-foreground-color: #fff;
 $primary-foreground-opacity: 0.9;
 $primary-foreground-opacity-hover: 1;
 $primary-background-color: #000;
@@ -11,9 +11,12 @@ $control-bar-height: 34px;
 
 $slider-bg-color: lighten($primary-background-color, 33%);
 
+$setting-transition-duration: 0.15s;
+$setting-transition-easing: ease-out;
+
 .video-js.vjs-peertube-skin {
   font-size: $font-size;
-  color: #fff;
+  color: $primary-foreground-color;
 
   .vjs-dock-text {
     padding-right: 10px;
@@ -22,16 +25,16 @@ $slider-bg-color: lighten($primary-background-color, 33%);
   .vjs-dock-description {
     font-size: 11px;
 
-    &:before, &:after {
+    &::before, &::after {
       display: inline-block;
       content: '\1F308';
     }
 
-    &:before {
+    &::before {
       margin-right: 4px;
     }
 
-    &:after {
+    &::after {
       margin-left: 4px;
       transform: scale(-1, 1);
     }
@@ -41,7 +44,7 @@ $slider-bg-color: lighten($primary-background-color, 33%);
     line-height: $control-bar-height;
   }
 
-  .vjs-volume-level:before {
+  .vjs-volume-level::before {
     content: ''; /* Remove Circle From Progress Bar */
   }
 
@@ -95,7 +98,7 @@ $slider-bg-color: lighten($primary-background-color, 33%);
 
   .vjs-control-bar,
   .vjs-big-play-button,
-  .vjs-menu-button .vjs-menu-content {
+  .vjs-settings-dialog {
     background-color: rgba($primary-background-color, 0.5);
   }
 
@@ -110,8 +113,13 @@ $slider-bg-color: lighten($primary-background-color, 33%);
   }
 
   .vjs-play-progress {
-    &::before:hover {
-      top: -0.372em;
+
+    &::before {
+      top: -0.3em;
+
+      &:hover {
+        top: -0.372em;
+      }
     }
 
     .vjs-time-tooltip {
@@ -141,8 +149,11 @@ $slider-bg-color: lighten($primary-background-color, 33%);
     .vjs-mute-control,
     .vjs-volume-control,
     .vjs-resolution-control,
-    .vjs-fullscreen-control
+    .vjs-fullscreen-control,
+    .vjs-peertube-link,
+    .vjs-settings
     {
+      color: $primary-foreground-color !important;
       opacity: $primary-foreground-opacity;
       transition: opacity .1s;
 
@@ -155,6 +166,7 @@ $slider-bg-color: lighten($primary-background-color, 33%);
     .vjs-duration,
     .vjs-peertube {
       color: $primary-foreground-color;
+      opacity: $primary-foreground-opacity;
     }
 
     .vjs-progress-control {
@@ -172,6 +184,7 @@ $slider-bg-color: lighten($primary-background-color, 33%);
     .vjs-play-control {
       @include disable-outline;
 
+      cursor: pointer;
       font-size: $font-size;
       padding: 0 17px;
       margin-right: 5px;
@@ -291,7 +304,7 @@ $slider-bg-color: lighten($primary-background-color, 33%);
 
     .vjs-volume-control {
       width: 30px;
-      margin: 0;
+      margin: 0 5px 0 0;
     }
 
     .vjs-volume-bar {
@@ -348,6 +361,16 @@ $slider-bg-color: lighten($primary-background-color, 33%);
       }
     }
 
+    .vjs-peertube-link {
+      @include disable-outline;
+      @include disable-default-a-behaviour;
+
+      text-decoration: none;
+      line-height: $control-bar-height;
+      font-weight: $font-semibold;
+      padding: 0 5px;
+    }
+
     .vjs-fullscreen-control {
       @include disable-outline;
 
@@ -371,19 +394,6 @@ $slider-bg-color: lighten($primary-background-color, 33%);
       font-weight: $font-semibold;
       width: 50px;
 
-      // Thanks: https://github.com/kmoskwiak/videojs-resolution-switcher/pull/92/files
-      .vjs-resolution-button-label {
-        line-height: $control-bar-height;
-        position: absolute;
-        top: 0;
-        left: 0;
-        width: 100%;
-        height: 100%;
-        text-align: center;
-        box-sizing: inherit;
-        text-align: center;
-      }
-
       .vjs-resolution-button {
         @include disable-outline;
       }
@@ -451,6 +461,35 @@ $slider-bg-color: lighten($primary-background-color, 33%);
   }
 }
 
+// Play/pause animations
+.vjs-has-started .vjs-play-control {
+  &.vjs-playing {
+    animation: remove-pause-button 0.25s ease;
+  }
+
+  &.vjs-paused {
+    animation: add-play-button 0.25s ease;
+  }
+
+  @keyframes remove-pause-button {
+    0% {
+      transform: rotate(90deg);
+    }
+    100% {
+      transform: rotate(0deg);
+    }
+  }
+
+  @keyframes add-play-button {
+    0% {
+      transform: rotate(-90deg);
+    }
+    100% {
+      transform: rotate(0deg);
+    }
+  }
+}
+
 // Thanks: https://projects.lukehaas.me/css-loaders/
 .vjs-loading-spinner {
   left: 50%;
@@ -463,11 +502,11 @@ $slider-bg-color: lighten($primary-background-color, 33%);
   overflow: hidden;
   visibility: hidden;
 
-  &:before {
+  &::before {
     animation: none !important;
   }
 
-  &:after {
+  &::after {
     border-radius: 50%;
     width: 6em;
     height: 6em;
@@ -520,3 +559,169 @@ $slider-bg-color: lighten($primary-background-color, 33%);
     display: block;
   }
 }
+
+
+/* Sass for videojs-settings-menu */
+
+.video-js {
+
+  .vjs-settings {
+    @include disable-outline;
+
+    cursor: pointer;
+    width: 37px;
+
+    .vjs-icon-placeholder {
+      display: inline-block;
+      width: 17px;
+      height: 17px;
+      vertical-align: middle;
+      background: url('../assets/player/images/settings.svg') no-repeat;
+      background-size: contain;
+
+      &::before {
+        content: '';
+      }
+    }
+  }
+
+  .vjs-settings-sub-menu-title {
+    width: 4em;
+    text-transform: initial;
+  }
+
+  .vjs-settings-dialog {
+    position: absolute;
+    right: .5em;
+    bottom: 3.5em;
+    color: $primary-foreground-color;
+    opacity: $primary-foreground-opacity;
+    margin: 0 auto;
+    font-size: $font-size !important;
+
+    width: auto;
+    overflow: hidden;
+
+    transition: width $setting-transition-duration $setting-transition-easing,  height $setting-transition-duration $setting-transition-easing;
+
+    .vjs-settings-sub-menu-value,
+    .vjs-settings-sub-menu-title {
+      display: table-cell;
+      padding: 0 5px;
+    }
+
+    .vjs-settings-sub-menu-title {
+      text-align: left;
+      font-weight: $font-semibold;
+    }
+
+    .vjs-settings-sub-menu-value {
+      width: 100%;
+      text-align: right;
+    }
+
+    .vjs-settings-panel {
+      position: absolute;
+      bottom: 0;
+      right: 0;
+      overflow-y: auto;
+      overflow-x: hidden;
+      border-radius: 1px;
+    }
+
+    .vjs-settings-panel-child {
+      display: flex;
+
+      align-items: flex-end;
+      white-space: nowrap;
+
+      &:focus,
+      &:active {
+        outline: none;
+      }
+
+      > .vjs-menu {
+        flex: 1;
+        min-width: 200px;
+      }
+
+      > .vjs-menu,
+      > .vjs-settings-sub-menu {
+        transition: all $setting-transition-duration $setting-transition-easing;
+
+        .vjs-menu-item {
+
+          &:first-child {
+            margin-top: 5px;
+          }
+
+          &:last-child {
+            margin-bottom: 5px;
+          }
+        }
+
+        li {
+          font-size: 1em;
+          text-transform: initial;
+
+          &:hover {
+            cursor: pointer;
+          }
+        }
+      }
+
+      > .vjs-menu {
+        .vjs-menu-item {
+          padding: 8px 16px;
+        }
+
+        .vjs-settings-sub-menu-value::after {
+          @include chevron-right(9px, 2px);
+
+          margin-left: 5px;
+        }
+      }
+
+      > .vjs-settings-sub-menu {
+        width: 80px;
+
+        .vjs-menu-item {
+          outline: 0;
+          font-weight: $font-semibold;
+
+          padding: 5px 8px;
+          text-align: right;
+
+          &.vjs-back-button {
+            background-color: inherit;
+            padding: 8px 8px 13px 8px;
+            margin-bottom: 5px;
+            border-bottom: 1px solid grey;
+
+            &::before {
+              @include chevron-left(9px, 2px);
+
+              margin-right: 5px;
+            }
+          }
+
+          &.vjs-selected {
+            background-color: inherit;
+            color: inherit;
+            position: relative;
+
+            &::before {
+              @include icon(15px);
+
+              position: absolute;
+              left: 8px;
+              content: ' ';
+              margin-top: 1px;
+              background-image: url('../assets/player/images/tick.svg');
+            }
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
index 9fa868c9b966b26ffc579ca14dbc8f8c6da5aab9..b015c67365b4c7eb12ccde2964ef5848fab43b47 100644 (file)
@@ -14,8 +14,6 @@ html, body {
   margin: 0;
 }
 
-
-
 .video-js.vjs-peertube-skin {
   width: 100%;
   height: 100%;
@@ -25,22 +23,6 @@ html, body {
     background-size: 100% auto;
   }
 
-  .vjs-peertube-link {
-    @include disable-outline;
-
-    color: #fff;
-    text-decoration: none;
-    font-size: $font-size;
-    line-height: $control-bar-height;
-    transition: all .4s;
-    font-weight: $font-semibold;
-    padding-right: 5px;
-  }
-
-  .vjs-peertube-link:hover {
-    text-shadow: 0 0 1em #fff;
-  }
-
   @media screen and (max-width: 350px) {
     .vjs-play-control {
       padding: 0 5px !important;
index 08f2955cfe0184997de402d0420eb06410db1186..f2ac5dca6439a900df037cf1267b8db501bfee2a 100644 (file)
@@ -1,10 +1,9 @@
 import './embed.scss'
 
 import * as videojs from 'video.js'
-import 'videojs-hotkeys'
-import '../../assets/player/peertube-videojs-plugin'
-import 'videojs-dock/dist/videojs-dock.es.js'
+
 import { VideoDetails } from '../../../../shared'
+import { getVideojsOptions } from '../../assets/player/peertube-player'
 
 function getVideoUrl (id: string) {
   return window.location.origin + '/api/v1/videos/' + id
@@ -20,9 +19,10 @@ const videoId = urlParts[urlParts.length - 1]
 
 loadVideoInfo(videoId)
   .then(videoInfo => {
-    const videoElement = document.getElementById('video-container') as HTMLVideoElement
-    const previewUrl = window.location.origin + videoInfo.previewPath
-    videoElement.poster = previewUrl
+    const videoContainerId = 'video-container'
+
+    const videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
+    videoElement.poster = window.location.origin + videoInfo.previewPath
 
     let autoplay = false
 
@@ -33,45 +33,17 @@ loadVideoInfo(videoId)
       console.error('Cannot get params from URL.', err)
     }
 
-    const videojsOptions = {
-      controls: true,
+    const videojsOptions = getVideojsOptions({
       autoplay,
-      inactivityTimeout: 500,
-      plugins: {
-        peertube: {
-          videoFiles: videoInfo.files,
-          playerElement: videoElement,
-          videoViewUrl: getVideoUrl(videoId) + '/views',
-          videoDuration: videoInfo.duration
-        },
-        hotkeys: {
-          enableVolumeScroll: false
-        }
-      },
-      controlBar: {
-        children: [
-          'playToggle',
-          'currentTimeDisplay',
-          'timeDivider',
-          'durationDisplay',
-          'liveDisplay',
-
-          'flexibleWidthSpacer',
-          'progressControl',
-
-          'webTorrentButton',
-
-          'muteToggle',
-          'volumeControl',
-
-          'resolutionMenuButton',
-          'peerTubeLinkButton',
-
-          'fullscreenToggle'
-        ]
-      }
-    }
-    videojs('video-container', videojsOptions, function () {
+      inactivityTimeout: 1500,
+      videoViewUrl: getVideoUrl(videoId) + '/views',
+      playerElement: videoElement,
+      videoFiles: videoInfo.files,
+      videoDuration: videoInfo.duration,
+      enableHotkeys: true,
+      peertubeLink: true
+    })
+    videojs(videoContainerId, videojsOptions, function () {
       const player = this
 
       player.dock({