diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index fda69efab..c7e26fad2 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -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
index 000000000..c663087b7
--- /dev/null
+++ b/client/src/assets/player/images/settings.svg
@@ -0,0 +1,14 @@
+
+
diff --git a/client/src/assets/player/images/tick.svg b/client/src/assets/player/images/tick.svg
new file mode 100644
index 000000000..d329e6bfb
--- /dev/null
+++ b/client/src/assets/player/images/tick.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts
new file mode 100644
index 000000000..6ead78c00
--- /dev/null
+++ b/client/src/assets/player/peertube-link-button.ts
@@ -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
index 000000000..4ae3e71bd
--- /dev/null
+++ b/client/src/assets/player/peertube-player.ts
@@ -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 }
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts
index 22cb27da3..c35ce12cb 100644
--- a/client/src/assets/player/peertube-videojs-plugin.ts
+++ b/client/src/assets/player/peertube-videojs-plugin.ts
@@ -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
index 000000000..a58fa6505
--- /dev/null
+++ b/client/src/assets/player/peertube-videojs-typings.ts
@@ -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
index 000000000..c927b084d
--- /dev/null
+++ b/client/src/assets/player/resolution-menu-button.ts
@@ -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
index 000000000..95e0ed1f8
--- /dev/null
+++ b/client/src/assets/player/resolution-menu-item.ts
@@ -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
index 000000000..c48e1382c
--- /dev/null
+++ b/client/src/assets/player/settings-menu-button.ts
@@ -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
index 000000000..e979ae088
--- /dev/null
+++ b/client/src/assets/player/settings-menu-item.ts
@@ -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
index 000000000..7a99dba1a
--- /dev/null
+++ b/client/src/assets/player/utils.ts
@@ -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
index 000000000..8a79e0e50
--- /dev/null
+++ b/client/src/assets/player/webtorrent-info-button.ts
@@ -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)
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index e1b1bb32c..f905f9ae5 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -279,3 +279,27 @@
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);
+}
diff --git a/client/src/sass/video-js-custom.scss b/client/src/sass/video-js-custom.scss
index 2fa3527a8..2c589553c 100644
--- a/client/src/sass/video-js-custom.scss
+++ b/client/src/sass/video-js-custom.scss
@@ -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
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 9fa868c9b..b015c6736 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -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;
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 08f2955cf..f2ac5dca6 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -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({