From 64cc5e8575fda47b281ae20abf0020e27fc8ce7c Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Fri, 5 Oct 2018 15:17:34 +0200 Subject: add webtorrent opt-out settings - add a key in localstorage to remember the opt-out - add a user setting --- client/src/assets/player/peertube-player-local-storage.ts | 10 ++++++++++ client/src/assets/player/peertube-videojs-plugin.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-player-local-storage.ts b/client/src/assets/player/peertube-player-local-storage.ts index dac54c5a4..c3d8b71bc 100644 --- a/client/src/assets/player/peertube-player-local-storage.ts +++ b/client/src/assets/player/peertube-player-local-storage.ts @@ -10,6 +10,15 @@ function getStoredVolume () { return undefined } +function getStoredWebTorrentPolicy () { + const value = getLocalStorage('webtorrent_policy') + if (value !== null && value !== undefined) { + if (value.toString() === 'disable') return true + } + + return undefined +} + function getStoredMute () { const value = getLocalStorage('mute') if (value !== null && value !== undefined) return value === 'true' @@ -56,6 +65,7 @@ function getAverageBandwidthInStore () { export { getStoredVolume, + getStoredWebTorrentPolicy, getStoredMute, getStoredTheater, saveVolumeInStore, diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 2330f476f..90ca8f9fa 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -8,6 +8,7 @@ import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution import * as CacheChunkStore from 'cache-chunk-store' import { PeertubeChunkStore } from './peertube-chunk-store' import { + getStoredWebTorrentPolicy, getAverageBandwidthInStore, getStoredMute, getStoredVolume, @@ -64,6 +65,7 @@ class PeerTubePlugin extends Plugin { private autoResolution = true private forbidAutoResolution = false private isAutoResolutionObservation = false + private playerRefusedP2P = false private videoViewInterval private torrentInfoInterval @@ -97,6 +99,7 @@ class PeerTubePlugin extends Plugin { if (volume !== undefined) this.player.volume(volume) const muted = getStoredMute() if (muted !== undefined) this.player.muted(muted) + this.playerRefusedP2P = getStoredWebTorrentPolicy() || false this.initializePlayer() this.runTorrentInfoScheduler() @@ -288,7 +291,8 @@ class PeerTubePlugin extends Plugin { renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { this.renderer = renderer - if (err) return this.fallbackToHttp(done) + console.log('value this.playerRefusedP2P', this.playerRefusedP2P) + if (err || this.playerRefusedP2P) return this.fallbackToHttp(done) return this.tryToPlay(err => { if (err) return done(err) -- cgit v1.2.3 From ed638e5325096ef580da20f370ac61c59cd48cf7 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Fri, 12 Oct 2018 18:12:39 +0200 Subject: move to boolean switch --- client/src/assets/player/peertube-player-local-storage.ts | 12 +++++------- client/src/assets/player/peertube-videojs-plugin.ts | 5 ++--- 2 files changed, 7 insertions(+), 10 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-player-local-storage.ts b/client/src/assets/player/peertube-player-local-storage.ts index c3d8b71bc..3ac5fe58a 100644 --- a/client/src/assets/player/peertube-player-local-storage.ts +++ b/client/src/assets/player/peertube-player-local-storage.ts @@ -10,13 +10,11 @@ function getStoredVolume () { return undefined } -function getStoredWebTorrentPolicy () { - const value = getLocalStorage('webtorrent_policy') - if (value !== null && value !== undefined) { - if (value.toString() === 'disable') return true - } +function getStoredWebTorrentEnabled (): boolean { + const value = getLocalStorage('webtorrent_enabled') + if (value !== null && value !== undefined) return value === 'true' - return undefined + return false } function getStoredMute () { @@ -65,7 +63,7 @@ function getAverageBandwidthInStore () { export { getStoredVolume, - getStoredWebTorrentPolicy, + getStoredWebTorrentEnabled, getStoredMute, getStoredTheater, saveVolumeInStore, diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 90ca8f9fa..a53a2cc69 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -8,7 +8,7 @@ import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution import * as CacheChunkStore from 'cache-chunk-store' import { PeertubeChunkStore } from './peertube-chunk-store' import { - getStoredWebTorrentPolicy, + getStoredWebTorrentEnabled, getAverageBandwidthInStore, getStoredMute, getStoredVolume, @@ -82,6 +82,7 @@ class PeerTubePlugin extends Plugin { // Disable auto play on iOS this.autoplay = options.autoplay && this.isIOS() === false + this.playerRefusedP2P = !getStoredWebTorrentEnabled() this.startTime = timeToInt(options.startTime) this.videoFiles = options.videoFiles @@ -99,7 +100,6 @@ class PeerTubePlugin extends Plugin { if (volume !== undefined) this.player.volume(volume) const muted = getStoredMute() if (muted !== undefined) this.player.muted(muted) - this.playerRefusedP2P = getStoredWebTorrentPolicy() || false this.initializePlayer() this.runTorrentInfoScheduler() @@ -291,7 +291,6 @@ class PeerTubePlugin extends Plugin { renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { this.renderer = renderer - console.log('value this.playerRefusedP2P', this.playerRefusedP2P) if (err || this.playerRefusedP2P) return this.fallbackToHttp(done) return this.tryToPlay(err => { -- cgit v1.2.3 From a73115f31ae891cb47759f075b1d2cead40817a4 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 17 Oct 2018 10:47:01 +0200 Subject: Fix webtorrent disabling --- .../src/assets/player/peertube-videojs-plugin.ts | 47 ++++++++++++++-------- 1 file changed, 30 insertions(+), 17 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index a53a2cc69..5cebab6d9 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -8,15 +8,21 @@ import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution import * as CacheChunkStore from 'cache-chunk-store' import { PeertubeChunkStore } from './peertube-chunk-store' import { - getStoredWebTorrentEnabled, getAverageBandwidthInStore, getStoredMute, getStoredVolume, + getStoredWebTorrentEnabled, saveAverageBandwidth, saveMuteInStore, saveVolumeInStore } from './peertube-player-local-storage' +type PlayOptions = { + forcePlay?: boolean, + seek?: number, + delay?: number +} + const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') class PeerTubePlugin extends Plugin { private readonly playerElement: HTMLVideoElement @@ -181,6 +187,15 @@ class PeerTubePlugin extends Plugin { const previousVideoFile = this.currentVideoFile this.currentVideoFile = videoFile + // Don't try on iOS that does not support MediaSource + // Or don't use P2P if webtorrent is disabled + if (this.isIOS() || this.playerRefusedP2P) { + return this.fallbackToHttp(options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + } + this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { this.player.playbackRate(oldPlaybackRate) return done() @@ -251,11 +266,7 @@ class PeerTubePlugin extends Plugin { private addTorrent ( magnetOrTorrentUrl: string, previousVideoFile: VideoFile, - options: { - forcePlay?: boolean, - seek?: number, - delay?: number - }, + options: PlayOptions, done: Function ) { console.log('Adding ' + magnetOrTorrentUrl + '.') @@ -291,7 +302,7 @@ class PeerTubePlugin extends Plugin { renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { this.renderer = renderer - if (err || this.playerRefusedP2P) return this.fallbackToHttp(done) + if (err) return this.fallbackToHttp(options, done) return this.tryToPlay(err => { if (err) return done(err) @@ -299,7 +310,7 @@ class PeerTubePlugin extends Plugin { if (options.seek) this.seek(options.seek) if (options.forcePlay === false && paused === true) this.player.pause() - return done(err) + return done() }) }) }, options.delay || 0) @@ -435,12 +446,6 @@ class PeerTubePlugin extends Plugin { return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) } - // Don't try on iOS that does not support MediaSource - if (this.isIOS()) { - this.currentVideoFile = this.pickAverageVideoFile() - return this.fallbackToHttp(undefined, false) - } - // Proxy first play const oldPlay = this.player.play.bind(this.player) this.player.play = () => { @@ -570,7 +575,9 @@ class PeerTubePlugin extends Plugin { return fetch(url, { method: 'PUT', body, headers }) } - private fallbackToHttp (done?: Function, play = true) { + private fallbackToHttp (options: PlayOptions, done?: Function) { + const paused = this.player.paused() + this.disableAutoResolution(true) this.flushVideoFile(this.currentVideoFile, true) @@ -582,9 +589,15 @@ class PeerTubePlugin extends Plugin { const httpUrl = this.currentVideoFile.fileUrl this.player.src = this.savePlayerSrcFunction this.player.src(httpUrl) - if (play) this.tryToPlay() - if (done) return done() + return this.tryToPlay(err => { + if (err && done) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + if (done) return done() + }) } private handleError (err: Error | string) { -- cgit v1.2.3 From 244b4ae3973bc1511464a08158a123767f83179c Mon Sep 17 00:00:00 2001 From: BO41 Date: Thu, 18 Oct 2018 09:08:59 +0200 Subject: NoImplicitAny flag true (#1157) this enables the `noImplicitAny` flag in the Typescript compiler > When the noImplicitAny flag is true and the TypeScript compiler cannot infer the type, it still generates the JavaScript files, but it also reports an error. Many seasoned developers prefer this stricter setting because type checking catches more unintentional errors at compile time. closes: #1131 replaces #1137 --- client/src/assets/player/peertube-chunk-store.ts | 16 ++++----- client/src/assets/player/peertube-link-button.ts | 3 +- .../assets/player/peertube-load-progress-bar.ts | 2 +- client/src/assets/player/peertube-player.ts | 16 ++++----- .../src/assets/player/peertube-videojs-plugin.ts | 40 +++++++++++----------- .../src/assets/player/peertube-videojs-typings.ts | 6 ++-- client/src/assets/player/resolution-menu-button.ts | 3 +- client/src/assets/player/resolution-menu-item.ts | 5 ++- client/src/assets/player/settings-menu-button.ts | 18 +++++----- client/src/assets/player/settings-menu-item.ts | 11 +++--- client/src/assets/player/theater-button.ts | 2 +- client/src/assets/player/utils.ts | 2 +- client/src/assets/player/video-renderer.ts | 18 +++++----- client/src/assets/player/webtorrent-info-button.ts | 2 +- 14 files changed, 70 insertions(+), 74 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-chunk-store.ts b/client/src/assets/player/peertube-chunk-store.ts index 767e46821..ac3f9e654 100644 --- a/client/src/assets/player/peertube-chunk-store.ts +++ b/client/src/assets/player/peertube-chunk-store.ts @@ -40,15 +40,15 @@ export class PeertubeChunkStore extends EventEmitter { // If the store is full private memoryChunks: { [ id: number ]: Buffer | true } = {} private databaseName: string - private putBulkTimeout - private cleanerInterval + private putBulkTimeout: any + private cleanerInterval: any private db: ChunkDatabase private expirationDB: ExpirationDatabase private readonly length: number private readonly lastChunkLength: number private readonly lastChunkIndex: number - constructor (chunkLength: number, opts) { + constructor (chunkLength: number, opts: any) { super() this.databaseName = 'webtorrent-chunks-' @@ -113,13 +113,13 @@ export class PeertubeChunkStore extends EventEmitter { }, PeertubeChunkStore.BUFFERING_PUT_MS) } - get (index: number, opts, cb) { + get (index: number, opts: any, cb: any): any { if (typeof opts === 'function') return this.get(index, null, opts) // IndexDB could be slow, use our memory index first const memoryChunk = this.memoryChunks[index] if (memoryChunk === undefined) { - const err = new Error('Chunk not found') + const err = new Error('Chunk not found') as any err['notFound'] = true return process.nextTick(() => cb(err)) @@ -146,11 +146,11 @@ export class PeertubeChunkStore extends EventEmitter { }) } - close (db) { + close (db: any) { return this.destroy(db) } - async destroy (cb) { + async destroy (cb: any) { try { if (this.pendingPut) { clearTimeout(this.putBulkTimeout) @@ -225,7 +225,7 @@ export class PeertubeChunkStore extends EventEmitter { } } - private nextTick (cb, err, val?) { + private nextTick (cb: any, err: Error, val?: any) { process.nextTick(() => cb(err, val), undefined) } } diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts index 715207bc0..b03952b47 100644 --- a/client/src/assets/player/peertube-link-button.ts +++ b/client/src/assets/player/peertube-link-button.ts @@ -1,11 +1,10 @@ -import * as videojs from 'video.js' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { buildVideoLink } from './utils' const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') class PeerTubeLinkButton extends Button { - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { super(player, options) } diff --git a/client/src/assets/player/peertube-load-progress-bar.ts b/client/src/assets/player/peertube-load-progress-bar.ts index aedc641e4..ee8a6cd81 100644 --- a/client/src/assets/player/peertube-load-progress-bar.ts +++ b/client/src/assets/player/peertube-load-progress-bar.ts @@ -4,7 +4,7 @@ const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Compon class PeerTubeLoadProgressBar extends Component { - constructor (player, options) { + constructor (player: any, options: any) { super(player, options) this.partEls_ = [] this.on(player, 'progress', this.update) diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index 792662b6c..ef9e7fcc0 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -75,12 +75,12 @@ function getVideojsOptions (options: { enableVolumeScroll: false, enableModifiersForNumbers: false, - fullscreenKey: function (event) { + fullscreenKey: function (event: any) { // fullscreen with the f key or Ctrl+Enter return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') }, - seekStep: function (event) { + seekStep: function (event: any) { // mimic VLC seek behavior, and default to 5 (original value is 5). if (event.ctrlKey && event.altKey) { return 5 * 60 @@ -95,26 +95,26 @@ function getVideojsOptions (options: { customKeys: { increasePlaybackRateKey: { - key: function (event) { + key: function (event: any) { return event.key === '>' }, - handler: function (player) { + handler: function (player: any) { player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) } }, decreasePlaybackRateKey: { - key: function (event) { + key: function (event: any) { return event.key === '<' }, - handler: function (player) { + handler: function (player: any) { player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) } }, frameByFrame: { - key: function (event) { + key: function (event: any) { return event.key === '.' }, - handler: function (player, options, event) { + handler: function (player: any) { player.pause() // Calculate movement distance (assuming 30 fps) const dist = 1 / 30 diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 5cebab6d9..03def186e 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -1,11 +1,11 @@ -import * as videojs from 'video.js' +const videojs = require('video.js') import * as WebTorrent from 'webtorrent' import { VideoFile } from '../../../../shared/models/videos/video.model' import { renderVideo } from './video-renderer' import './settings-menu-button' import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' -import * as CacheChunkStore from 'cache-chunk-store' +const CacheChunkStore = require('cache-chunk-store') import { PeertubeChunkStore } from './peertube-chunk-store' import { getAverageBandwidthInStore, @@ -61,11 +61,11 @@ class PeerTubePlugin extends Plugin { private player: any private currentVideoFile: VideoFile - private torrent: WebTorrent.Torrent + private torrent: any private videoCaptions: VideoJSCaption[] - private renderer - private fakeRenderer + private renderer: any + private fakeRenderer: any private destoyingFakeRenderer = false private autoResolution = true @@ -73,17 +73,17 @@ class PeerTubePlugin extends Plugin { private isAutoResolutionObservation = false private playerRefusedP2P = false - private videoViewInterval - private torrentInfoInterval - private autoQualityInterval - private userWatchingVideoInterval - private addTorrentDelay - private qualityObservationTimer - private runAutoQualitySchedulerTimer + private videoViewInterval: any + private torrentInfoInterval: any + private autoQualityInterval: any + private userWatchingVideoInterval: any + private addTorrentDelay: any + private qualityObservationTimer: any + private runAutoQualitySchedulerTimer: any private downloadSpeeds: number[] = [] - constructor (player: videojs.Player, options: PeertubePluginOptions) { + constructor (player: any, options: PeertubePluginOptions) { super(player, options) // Disable auto play on iOS @@ -273,7 +273,7 @@ class PeerTubePlugin extends Plugin { const oldTorrent = this.torrent const torrentOptions = { - store: (chunkLength, storeOpts) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { + store: (chunkLength: any, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { max: 100 }) } @@ -304,7 +304,7 @@ class PeerTubePlugin extends Plugin { if (err) return this.fallbackToHttp(options, done) - return this.tryToPlay(err => { + return this.tryToPlay((err: Error) => { if (err) return done(err) if (options.seek) this.seek(options.seek) @@ -316,7 +316,7 @@ class PeerTubePlugin extends Plugin { }, options.delay || 0) }) - this.torrent.on('error', err => console.error(err)) + this.torrent.on('error', (err: any) => console.error(err)) this.torrent.on('warning', (err: any) => { // We don't support HTTP tracker but we don't care -> we use the web socket tracker @@ -350,7 +350,7 @@ class PeerTubePlugin extends Plugin { const playPromise = this.player.play() if (playPromise !== undefined) { return playPromise.then(done) - .catch(err => { + .catch((err: Error) => { if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) { return } @@ -627,7 +627,7 @@ class PeerTubePlugin extends Plugin { this.player.options_.inactivityTimeout = saveInactivityTimeout } - const settingsDialog = this.player.children_.find(c => c.name_ === 'SettingsDialog') + const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog') this.player.controlBar.on('mouseenter', () => disableInactivity()) settingsDialog.on('mouseenter', () => disableInactivity()) @@ -641,7 +641,7 @@ class PeerTubePlugin extends Plugin { return this.videoFiles[Math.floor(this.videoFiles.length / 2)] } - private stopTorrent (torrent: WebTorrent.Torrent) { + private stopTorrent (torrent: any) { torrent.pause() // Pause does not remove actual peers (in particular the webseed peer) torrent.removePeer(torrent[ 'ws' ]) @@ -703,7 +703,7 @@ class PeerTubePlugin extends Plugin { const percent = time / this.player_.duration() return percent >= 1 ? 1 : percent } - SeekBar.prototype.handleMouseMove = function handleMouseMove (event) { + SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { let newTime = this.calculateDistance(event) * this.player_.duration() if (newTime === this.player_.duration()) { newTime = newTime - 0.1 diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index b117007af..98a33077d 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -1,4 +1,4 @@ -import * as videojs from 'video.js' +const videojs = require('video.js') import { VideoFile } from '../../../../shared/models/videos/video.model' import { PeerTubePlugin } from './peertube-videojs-plugin' @@ -11,9 +11,9 @@ declare namespace videojs { interface VideoJSComponentInterface { _player: videojs.Player - new (player: videojs.Player, options?: any) + new (player: videojs.Player, options?: any): any - registerComponent (name: string, obj: any) + registerComponent (name: string, obj: any): any } type VideoJSCaption = { diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts index d53a24151..91818efc9 100644 --- a/client/src/assets/player/resolution-menu-button.ts +++ b/client/src/assets/player/resolution-menu-button.ts @@ -1,4 +1,3 @@ -import * as videojs from 'video.js' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { ResolutionMenuItem } from './resolution-menu-item' @@ -7,7 +6,7 @@ const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuB class ResolutionMenuButton extends MenuButton { label: HTMLElement - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { super(player, options) this.player = player diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts index 0ab0f53b5..afe490abb 100644 --- a/client/src/assets/player/resolution-menu-item.ts +++ b/client/src/assets/player/resolution-menu-item.ts @@ -1,10 +1,9 @@ -import * as videojs from 'video.js' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') class ResolutionMenuItem extends MenuItem { - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { const currentResolutionId = player.peertube().getCurrentResolutionId() options.selectable = true options.selected = options.id === currentResolutionId @@ -18,7 +17,7 @@ class ResolutionMenuItem extends MenuItem { player.peertube().on('autoResolutionUpdate', () => this.updateSelection()) } - handleClick (event) { + handleClick (event: any) { if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return super.handleClick(event) diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts index b51c52506..f0ccb5862 100644 --- a/client/src/assets/player/settings-menu-button.ts +++ b/client/src/assets/player/settings-menu-button.ts @@ -1,7 +1,7 @@ // Author: Yanko Shterev // Thanks https://github.com/yshterev/videojs-settings-menu -import * as videojs from 'video.js' +const videojs = require('video.js') import { SettingsMenuItem } from './settings-menu-item' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { toTitleCase } from './utils' @@ -11,7 +11,7 @@ const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') class SettingsButton extends Button { - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { super(player, options) this.playerComponent = player @@ -48,7 +48,7 @@ class SettingsButton extends Button { } } - onDisposeSettingsItem (event, name: string) { + onDisposeSettingsItem (name: string) { if (name === undefined) { let children = this.menu.children() @@ -74,7 +74,7 @@ class SettingsButton extends Button { } } - onAddSettingsItem (event, data) { + onAddSettingsItem (data: any) { const [ entry, options ] = data this.addMenuItem(entry, options) @@ -120,7 +120,7 @@ class SettingsButton extends Button { this.resetChildren() } - getComponentSize (element) { + getComponentSize (element: any) { let width: number = null let height: number = null @@ -178,7 +178,7 @@ class SettingsButton extends Button { this.panelChild.addChild(this.menu) } - addMenuItem (entry, options) { + addMenuItem (entry: any, options: any) { const openSubMenu = function () { if (videojsUntyped.dom.hasClass(this.el_, 'open')) { videojsUntyped.dom.removeClass(this.el_, 'open') @@ -218,7 +218,7 @@ class SettingsButton extends Button { } class SettingsPanel extends Component { - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { super(player, options) } @@ -232,7 +232,7 @@ class SettingsPanel extends Component { } class SettingsPanelChild extends Component { - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { super(player, options) } @@ -246,7 +246,7 @@ class SettingsPanelChild extends Component { } class SettingsDialog extends Component { - constructor (player: videojs.Player, options) { + constructor (player: any, options: any) { super(player, options) this.hide() } diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts index 665ce6fc2..2d752b62e 100644 --- a/client/src/assets/player/settings-menu-item.ts +++ b/client/src/assets/player/settings-menu-item.ts @@ -1,7 +1,6 @@ // Author: Yanko Shterev // Thanks https://github.com/yshterev/videojs-settings-menu -import * as videojs from 'video.js' import { toTitleCase } from './utils' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' @@ -10,7 +9,7 @@ const component: VideoJSComponentInterface = videojsUntyped.getComponent('Compon class SettingsMenuItem extends MenuItem { - constructor (player: videojs.Player, options, entry: string, menuButton: VideoJSComponentInterface) { + constructor (player: any, options: any, entry: string, menuButton: VideoJSComponentInterface) { super(player, options) this.settingsButton = menuButton @@ -55,7 +54,7 @@ class SettingsMenuItem extends MenuItem { this.transitionEndHandler = this.onTransitionEnd.bind(this) } - onSubmenuClick (event) { + onSubmenuClick (event: any) { let target = null if (event.type === 'tap') { @@ -150,7 +149,7 @@ class SettingsMenuItem extends MenuItem { * * @method PrefixedEvent */ - PrefixedEvent (element, type, callback, action = 'addEvent') { + PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { let prefix = ['webkit', 'moz', 'MS', 'o', ''] for (let p = 0; p < prefix.length; p++) { @@ -166,7 +165,7 @@ class SettingsMenuItem extends MenuItem { } } - onTransitionEnd (event) { + onTransitionEnd (event: any) { if (event.propertyName !== 'margin-right') { return } @@ -229,7 +228,7 @@ class SettingsMenuItem extends MenuItem { ) } - update (event?: Event) { + update (event?: any) { let target = null let subMenu = this.subMenu.name() diff --git a/client/src/assets/player/theater-button.ts b/client/src/assets/player/theater-button.ts index 5cf0b6425..b761f6030 100644 --- a/client/src/assets/player/theater-button.ts +++ b/client/src/assets/player/theater-button.ts @@ -6,7 +6,7 @@ class TheaterButton extends Button { private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' - constructor (player, options) { + constructor (player: any, options: any) { super(player, options) const enabled = getStoredTheater() diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index cf4f60f55..46081c0d2 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -12,7 +12,7 @@ const dictionaryBytes: Array<{max: number, type: string}> = [ { max: 1073741824, type: 'MB' }, { max: 1.0995116e12, type: 'GB' } ] -function bytes (value) { +function bytes (value: any) { const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] const calc = Math.floor(value / (format.max / 1024)).toString() diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/video-renderer.ts index 2cb05a448..a3415937b 100644 --- a/client/src/assets/player/video-renderer.ts +++ b/client/src/assets/player/video-renderer.ts @@ -1,9 +1,9 @@ // Thanks: https://github.com/feross/render-media // TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed -import * as MediaElementWrapper from 'mediasource' +const MediaElementWrapper = require('mediasource') import { extname } from 'path' -import * as videostream from 'videostream' +const videostream = require('videostream') const VIDEOSTREAM_EXTS = [ '.m4a', @@ -17,7 +17,7 @@ type RenderMediaOptions = { } function renderVideo ( - file, + file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer: any) => void @@ -27,11 +27,11 @@ function renderVideo ( return renderMedia(file, elem, opts, callback) } -function renderMedia (file, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { +function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { const extension = extname(file.name).toLowerCase() - let preparedElem = undefined + let preparedElem: any = undefined let currentTime = 0 - let renderer + let renderer: any try { if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) { @@ -45,7 +45,7 @@ function renderMedia (file, elem: HTMLVideoElement, opts: RenderMediaOptions, ca function useVideostream () { prepareElem() - preparedElem.addEventListener('error', function onError (err) { + preparedElem.addEventListener('error', function onError (err: Error) { preparedElem.removeEventListener('error', onError) return callback(err) @@ -58,7 +58,7 @@ function renderMedia (file, elem: HTMLVideoElement, opts: RenderMediaOptions, ca const codecs = getCodec(file.name, useVP9) prepareElem() - preparedElem.addEventListener('error', function onError (err) { + preparedElem.addEventListener('error', function onError (err: Error) { preparedElem.removeEventListener('error', onError) // Try with vp9 before returning an error @@ -102,7 +102,7 @@ function renderMedia (file, elem: HTMLVideoElement, opts: RenderMediaOptions, ca } } -function validateFile (file) { +function validateFile (file: any) { if (file == null) { throw new Error('file cannot be null or undefined') } diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts index deef253ce..5b9d0a401 100644 --- a/client/src/assets/player/webtorrent-info-button.ts +++ b/client/src/assets/player/webtorrent-info-button.ts @@ -65,7 +65,7 @@ class WebtorrentInfoButton extends Button { subDivHttp.appendChild(subDivHttpText) div.appendChild(subDivHttp) - this.player_.peertube().on('torrentInfo', (event, data) => { + this.player_.peertube().on('torrentInfo', (data: any) => { // We are in HTTP fallback if (!data) { subDivHttp.className = 'vjs-peertube-displayed' -- cgit v1.2.3 From c199c427d4ae586339822320f20f512a7a19dc3f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 18 Oct 2018 14:35:31 +0200 Subject: Better typings --- client/src/assets/player/peertube-chunk-store.ts | 12 ++++++------ client/src/assets/player/peertube-link-button.ts | 5 ++++- .../src/assets/player/peertube-load-progress-bar.ts | 5 ++++- client/src/assets/player/peertube-player.ts | 20 ++++++++++++-------- client/src/assets/player/peertube-videojs-plugin.ts | 20 ++++++++++++-------- client/src/assets/player/peertube-videojs-typings.ts | 5 ++++- client/src/assets/player/resolution-menu-button.ts | 6 +++++- client/src/assets/player/resolution-menu-item.ts | 6 +++++- client/src/assets/player/settings-menu-button.ts | 17 ++++++++++------- client/src/assets/player/settings-menu-item.ts | 8 ++++++-- client/src/assets/player/theater-button.ts | 6 +++++- client/src/assets/player/utils.ts | 2 +- client/src/assets/player/webtorrent-info-button.ts | 2 +- 13 files changed, 75 insertions(+), 39 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-chunk-store.ts b/client/src/assets/player/peertube-chunk-store.ts index ac3f9e654..54cc0ea64 100644 --- a/client/src/assets/player/peertube-chunk-store.ts +++ b/client/src/assets/player/peertube-chunk-store.ts @@ -76,7 +76,7 @@ export class PeertubeChunkStore extends EventEmitter { this.runCleaner() } - put (index: number, buf: Buffer, cb: Function) { + put (index: number, buf: Buffer, cb: (err?: Error) => void) { const isLastChunk = (index === this.lastChunkIndex) if (isLastChunk && buf.length !== this.lastChunkLength) { return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) @@ -113,7 +113,7 @@ export class PeertubeChunkStore extends EventEmitter { }, PeertubeChunkStore.BUFFERING_PUT_MS) } - get (index: number, opts: any, cb: any): any { + get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { if (typeof opts === 'function') return this.get(index, null, opts) // IndexDB could be slow, use our memory index first @@ -146,11 +146,11 @@ export class PeertubeChunkStore extends EventEmitter { }) } - close (db: any) { - return this.destroy(db) + close (cb: (err?: Error) => void) { + return this.destroy(cb) } - async destroy (cb: any) { + async destroy (cb: (err?: Error) => void) { try { if (this.pendingPut) { clearTimeout(this.putBulkTimeout) @@ -225,7 +225,7 @@ export class PeertubeChunkStore extends EventEmitter { } } - private nextTick (cb: any, err: Error, val?: any) { + private nextTick (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { process.nextTick(() => cb(err, val), undefined) } } diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts index b03952b47..de9a49de9 100644 --- a/client/src/assets/player/peertube-link-button.ts +++ b/client/src/assets/player/peertube-link-button.ts @@ -1,10 +1,13 @@ import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { buildVideoLink } from './utils' +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') class PeerTubeLinkButton extends Button { - constructor (player: any, options: any) { + constructor (player: Player, options: any) { super(player, options) } diff --git a/client/src/assets/player/peertube-load-progress-bar.ts b/client/src/assets/player/peertube-load-progress-bar.ts index ee8a6cd81..af276d1b2 100644 --- a/client/src/assets/player/peertube-load-progress-bar.ts +++ b/client/src/assets/player/peertube-load-progress-bar.ts @@ -1,10 +1,13 @@ import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') class PeerTubeLoadProgressBar extends Component { - constructor (player: any, options: any) { + constructor (player: Player, options: any) { super(player, options) this.partEls_ = [] this.on(player, 'progress', this.update) diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index ef9e7fcc0..db63071cb 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -14,6 +14,10 @@ import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' // Change Captions to Subtitles/CC @@ -75,12 +79,12 @@ function getVideojsOptions (options: { enableVolumeScroll: false, enableModifiersForNumbers: false, - fullscreenKey: function (event: any) { + fullscreenKey: function (event: KeyboardEvent) { // fullscreen with the f key or Ctrl+Enter return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') }, - seekStep: function (event: any) { + seekStep: function (event: KeyboardEvent) { // mimic VLC seek behavior, and default to 5 (original value is 5). if (event.ctrlKey && event.altKey) { return 5 * 60 @@ -95,26 +99,26 @@ function getVideojsOptions (options: { customKeys: { increasePlaybackRateKey: { - key: function (event: any) { + key: function (event: KeyboardEvent) { return event.key === '>' }, - handler: function (player: any) { + handler: function (player: Player) { player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) } }, decreasePlaybackRateKey: { - key: function (event: any) { + key: function (event: KeyboardEvent) { return event.key === '<' }, - handler: function (player: any) { + handler: function (player: Player) { player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) } }, frameByFrame: { - key: function (event: any) { + key: function (event: KeyboardEvent) { return event.key === '.' }, - handler: function (player: any) { + handler: function (player: Player) { player.pause() // Calculate movement distance (assuming 30 fps) const dist = 1 / 30 diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 03def186e..40da5f1f7 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -1,11 +1,13 @@ -const videojs = require('video.js') +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + import * as WebTorrent from 'webtorrent' import { VideoFile } from '../../../../shared/models/videos/video.model' import { renderVideo } from './video-renderer' import './settings-menu-button' import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' -const CacheChunkStore = require('cache-chunk-store') import { PeertubeChunkStore } from './peertube-chunk-store' import { getAverageBandwidthInStore, @@ -17,6 +19,8 @@ import { saveVolumeInStore } from './peertube-player-local-storage' +const CacheChunkStore = require('cache-chunk-store') + type PlayOptions = { forcePlay?: boolean, seek?: number, @@ -61,7 +65,7 @@ class PeerTubePlugin extends Plugin { private player: any private currentVideoFile: VideoFile - private torrent: any + private torrent: WebTorrent.Torrent private videoCaptions: VideoJSCaption[] private renderer: any @@ -83,7 +87,7 @@ class PeerTubePlugin extends Plugin { private downloadSpeeds: number[] = [] - constructor (player: any, options: PeertubePluginOptions) { + constructor (player: videojs.Player, options: PeertubePluginOptions) { super(player, options) // Disable auto play on iOS @@ -273,7 +277,7 @@ class PeerTubePlugin extends Plugin { const oldTorrent = this.torrent const torrentOptions = { - store: (chunkLength: any, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { + store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { max: 100 }) } @@ -304,7 +308,7 @@ class PeerTubePlugin extends Plugin { if (err) return this.fallbackToHttp(options, done) - return this.tryToPlay((err: Error) => { + return this.tryToPlay(err => { if (err) return done(err) if (options.seek) this.seek(options.seek) @@ -344,7 +348,7 @@ class PeerTubePlugin extends Plugin { }) } - private tryToPlay (done?: Function) { + private tryToPlay (done?: (err?: Error) => void) { if (!done) done = function () { /* empty */ } const playPromise = this.player.play() @@ -641,7 +645,7 @@ class PeerTubePlugin extends Plugin { return this.videoFiles[Math.floor(this.videoFiles.length / 2)] } - private stopTorrent (torrent: any) { + private stopTorrent (torrent: WebTorrent.Torrent) { torrent.pause() // Pause does not remove actual peers (in particular the webseed peer) torrent.removePeer(torrent[ 'ws' ]) diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 98a33077d..d127230fa 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -1,4 +1,7 @@ -const videojs = require('video.js') +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + import { VideoFile } from '../../../../shared/models/videos/video.model' import { PeerTubePlugin } from './peertube-videojs-plugin' diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts index 91818efc9..a3c1108ca 100644 --- a/client/src/assets/player/resolution-menu-button.ts +++ b/client/src/assets/player/resolution-menu-button.ts @@ -1,3 +1,7 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { ResolutionMenuItem } from './resolution-menu-item' @@ -6,7 +10,7 @@ const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuB class ResolutionMenuButton extends MenuButton { label: HTMLElement - constructor (player: any, options: any) { + constructor (player: Player, options: any) { super(player, options) this.player = player diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts index afe490abb..b54fd91ef 100644 --- a/client/src/assets/player/resolution-menu-item.ts +++ b/client/src/assets/player/resolution-menu-item.ts @@ -1,9 +1,13 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') class ResolutionMenuItem extends MenuItem { - constructor (player: any, options: any) { + constructor (player: Player, options: any) { const currentResolutionId = player.peertube().getCurrentResolutionId() options.selectable = true options.selected = options.id === currentResolutionId diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts index f0ccb5862..aa7281727 100644 --- a/client/src/assets/player/settings-menu-button.ts +++ b/client/src/assets/player/settings-menu-button.ts @@ -1,7 +1,10 @@ // Author: Yanko Shterev // Thanks https://github.com/yshterev/videojs-settings-menu -const videojs = require('video.js') +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + import { SettingsMenuItem } from './settings-menu-item' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { toTitleCase } from './utils' @@ -11,7 +14,7 @@ const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') class SettingsButton extends Button { - constructor (player: any, options: any) { + constructor (player: videojs.Player, options: any) { super(player, options) this.playerComponent = player @@ -48,7 +51,7 @@ class SettingsButton extends Button { } } - onDisposeSettingsItem (name: string) { + onDisposeSettingsItem (event: any, name: string) { if (name === undefined) { let children = this.menu.children() @@ -74,7 +77,7 @@ class SettingsButton extends Button { } } - onAddSettingsItem (data: any) { + onAddSettingsItem (event: any, data: any) { const [ entry, options ] = data this.addMenuItem(entry, options) @@ -218,7 +221,7 @@ class SettingsButton extends Button { } class SettingsPanel extends Component { - constructor (player: any, options: any) { + constructor (player: videojs.Player, options: any) { super(player, options) } @@ -232,7 +235,7 @@ class SettingsPanel extends Component { } class SettingsPanelChild extends Component { - constructor (player: any, options: any) { + constructor (player: videojs.Player, options: any) { super(player, options) } @@ -246,7 +249,7 @@ class SettingsPanelChild extends Component { } class SettingsDialog extends Component { - constructor (player: any, options: any) { + constructor (player: videojs.Player, options: any) { super(player, options) this.hide() } diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts index 2d752b62e..698f4627a 100644 --- a/client/src/assets/player/settings-menu-item.ts +++ b/client/src/assets/player/settings-menu-item.ts @@ -1,6 +1,10 @@ // Author: Yanko Shterev // Thanks https://github.com/yshterev/videojs-settings-menu +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + import { toTitleCase } from './utils' import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' @@ -9,7 +13,7 @@ const component: VideoJSComponentInterface = videojsUntyped.getComponent('Compon class SettingsMenuItem extends MenuItem { - constructor (player: any, options: any, entry: string, menuButton: VideoJSComponentInterface) { + constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) { super(player, options) this.settingsButton = menuButton @@ -229,7 +233,7 @@ class SettingsMenuItem extends MenuItem { } update (event?: any) { - let target = null + let target: HTMLElement = null let subMenu = this.subMenu.name() if (event && event.type === 'tap') { diff --git a/client/src/assets/player/theater-button.ts b/client/src/assets/player/theater-button.ts index b761f6030..4f8fede3d 100644 --- a/client/src/assets/player/theater-button.ts +++ b/client/src/assets/player/theater-button.ts @@ -1,3 +1,7 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' import { saveTheaterInStore, getStoredTheater } from './peertube-player-local-storage' @@ -6,7 +10,7 @@ class TheaterButton extends Button { private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' - constructor (player: any, options: any) { + constructor (player: videojs.Player, options: any) { super(player, options) const enabled = getStoredTheater() diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index 46081c0d2..c87287482 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -12,7 +12,7 @@ const dictionaryBytes: Array<{max: number, type: string}> = [ { max: 1073741824, type: 'MB' }, { max: 1.0995116e12, type: 'GB' } ] -function bytes (value: any) { +function bytes (value: number) { const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] const calc = Math.floor(value / (format.max / 1024)).toString() diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts index 5b9d0a401..c3c1af951 100644 --- a/client/src/assets/player/webtorrent-info-button.ts +++ b/client/src/assets/player/webtorrent-info-button.ts @@ -65,7 +65,7 @@ class WebtorrentInfoButton extends Button { subDivHttp.appendChild(subDivHttpText) div.appendChild(subDivHttp) - this.player_.peertube().on('torrentInfo', (data: any) => { + this.player_.peertube().on('torrentInfo', (event: any, data: any) => { // We are in HTTP fallback if (!data) { subDivHttp.className = 'vjs-peertube-displayed' -- cgit v1.2.3 From e280dd0681440d05269d9aa54904d62064f2fa39 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 19 Oct 2018 14:56:02 +0200 Subject: Fix webtorrent disabled by default --- client/src/assets/player/peertube-player-local-storage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-player-local-storage.ts b/client/src/assets/player/peertube-player-local-storage.ts index 3ac5fe58a..7e3813570 100644 --- a/client/src/assets/player/peertube-player-local-storage.ts +++ b/client/src/assets/player/peertube-player-local-storage.ts @@ -14,7 +14,8 @@ function getStoredWebTorrentEnabled (): boolean { const value = getLocalStorage('webtorrent_enabled') if (value !== null && value !== undefined) return value === 'true' - return false + // By default webtorrent is enabled + return true } function getStoredMute () { -- cgit v1.2.3 From 951ef8294e9eae8f0b42292059daf0c972dbc48f Mon Sep 17 00:00:00 2001 From: BO41 Date: Wed, 24 Oct 2018 21:50:18 +0200 Subject: add noImplicitThis flag (#1324) --- client/src/assets/player/peertube-player.ts | 4 ++-- client/src/assets/player/settings-menu-button.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index db63071cb..aaa1170b6 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -213,7 +213,7 @@ function addContextMenu (player: any, videoEmbedUrl: string) { { label: player.localize('Copy the video URL at the current time'), listener: function () { - const player = this + const player = this as Player copyToClipboard(buildVideoLink(player.currentTime())) } }, @@ -226,7 +226,7 @@ function addContextMenu (player: any, videoEmbedUrl: string) { { label: player.localize('Copy magnet URI'), listener: function () { - const player = this + const player = this as Player copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri) } } diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts index aa7281727..a7aefdcc3 100644 --- a/client/src/assets/player/settings-menu-button.ts +++ b/client/src/assets/player/settings-menu-button.ts @@ -182,7 +182,7 @@ class SettingsButton extends Button { } addMenuItem (entry: any, options: any) { - const openSubMenu = function () { + const openSubMenu = function (this: any) { if (videojsUntyped.dom.hasClass(this.el_, 'open')) { videojsUntyped.dom.removeClass(this.el_, 'open') } else { -- cgit v1.2.3 From 2fbe7f1933f4bd5de96e6428234e56965616120e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 15 Nov 2018 09:24:56 +0100 Subject: Fix new Angular 7 issues --- client/src/assets/images/header/upload-white.svg | 16 ++++++++++++++++ client/src/assets/images/header/upload.svg | 16 ---------------- 2 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 client/src/assets/images/header/upload-white.svg delete mode 100644 client/src/assets/images/header/upload.svg (limited to 'client/src/assets') diff --git a/client/src/assets/images/header/upload-white.svg b/client/src/assets/images/header/upload-white.svg new file mode 100644 index 000000000..2b07caf76 --- /dev/null +++ b/client/src/assets/images/header/upload-white.svg @@ -0,0 +1,16 @@ + + + + cloud-upload + Created with Sketch. + + + + + + + + + + + diff --git a/client/src/assets/images/header/upload.svg b/client/src/assets/images/header/upload.svg deleted file mode 100644 index 2b07caf76..000000000 --- a/client/src/assets/images/header/upload.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - cloud-upload - Created with Sketch. - - - - - - - - - - - -- cgit v1.2.3 From fe05c3acbd48c72ac7e503bebde91830121a0bf1 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 16 Nov 2018 09:16:41 +0100 Subject: Fix player progress bar when changing resolution --- client/src/assets/player/images/tick-white.svg | 12 ++++++++++++ client/src/assets/player/images/tick.svg | 12 ------------ client/src/assets/player/peertube-videojs-plugin.ts | 5 +++++ 3 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 client/src/assets/player/images/tick-white.svg delete mode 100644 client/src/assets/player/images/tick.svg (limited to 'client/src/assets') diff --git a/client/src/assets/player/images/tick-white.svg b/client/src/assets/player/images/tick-white.svg new file mode 100644 index 000000000..d329e6bfb --- /dev/null +++ b/client/src/assets/player/images/tick-white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/src/assets/player/images/tick.svg b/client/src/assets/player/images/tick.svg deleted file mode 100644 index d329e6bfb..000000000 --- a/client/src/assets/player/images/tick.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 40da5f1f7..4fd5a9be2 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -111,6 +111,8 @@ class PeerTubePlugin extends Plugin { const muted = getStoredMute() if (muted !== undefined) this.player.muted(muted) + this.player.duration(options.videoDuration) + this.initializePlayer() this.runTorrentInfoScheduler() this.runViewAdd() @@ -302,6 +304,9 @@ class PeerTubePlugin extends Plugin { this.flushVideoFile(previousVideoFile) + // Update progress bar (just for the UI), do not wait rendering + if (options.seek) this.player.currentTime(options.seek) + const renderVideoOptions = { autoplay: false, controls: true } renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { this.renderer = renderer -- cgit v1.2.3 From 3b019808ef529cacce7f40706441670309e231d1 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 17 Dec 2018 14:14:54 +0100 Subject: Set last subtitle or subtitle in URL --- .../assets/player/peertube-player-local-storage.ts | 12 +++++- client/src/assets/player/peertube-player.ts | 36 ++++++++++-------- .../src/assets/player/peertube-videojs-plugin.ts | 44 ++++++++++++++++------ .../src/assets/player/peertube-videojs-typings.ts | 1 + client/src/assets/player/utils.ts | 1 + 5 files changed, 66 insertions(+), 28 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-player-local-storage.ts b/client/src/assets/player/peertube-player-local-storage.ts index 7e3813570..059fca308 100644 --- a/client/src/assets/player/peertube-player-local-storage.ts +++ b/client/src/assets/player/peertube-player-local-storage.ts @@ -60,6 +60,14 @@ function getAverageBandwidthInStore () { return undefined } +function saveLastSubtitle (language: string) { + return setLocalStorage('last-subtitle', language) +} + +function getStoredLastSubtitle () { + return getLocalStorage('last-subtitle') +} + // --------------------------------------------------------------------------- export { @@ -71,7 +79,9 @@ export { saveMuteInStore, saveTheaterInStore, saveAverageBandwidth, - getAverageBandwidthInStore + getAverageBandwidthInStore, + saveLastSubtitle, + getStoredLastSubtitle } // --------------------------------------------------------------------------- diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index aaa1170b6..e0e063838 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -26,23 +26,24 @@ videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitle videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' function getVideojsOptions (options: { - autoplay: boolean, - playerElement: HTMLVideoElement, - videoViewUrl: string, - videoDuration: number, - videoFiles: VideoFile[], - enableHotkeys: boolean, - inactivityTimeout: number, - peertubeLink: boolean, - poster: string, + autoplay: boolean + playerElement: HTMLVideoElement + videoViewUrl: string + videoDuration: number + videoFiles: VideoFile[] + enableHotkeys: boolean + inactivityTimeout: number + peertubeLink: boolean + poster: string startTime: number | string - theaterMode: boolean, - videoCaptions: VideoJSCaption[], + theaterMode: boolean + videoCaptions: VideoJSCaption[] - language?: string, - controls?: boolean, - muted?: boolean, + language?: string + controls?: boolean + muted?: boolean loop?: boolean + subtitle?: string userWatching?: UserWatching }) { @@ -50,8 +51,10 @@ function getVideojsOptions (options: { // We don't use text track settings for now textTrackSettings: false, controls: options.controls !== undefined ? options.controls : true, - muted: options.controls !== undefined ? options.muted : false, loop: options.loop !== undefined ? options.loop : false, + + muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage + poster: options.poster, autoplay: false, inactivityTimeout: options.inactivityTimeout, @@ -65,7 +68,8 @@ function getVideojsOptions (options: { videoViewUrl: options.videoViewUrl, videoDuration: options.videoDuration, startTime: options.startTime, - userWatching: options.userWatching + userWatching: options.userWatching, + subtitle: options.subtitle } }, controlBar: { diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 4fd5a9be2..4a280b7ef 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -11,10 +11,12 @@ import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution import { PeertubeChunkStore } from './peertube-chunk-store' import { getAverageBandwidthInStore, + getStoredLastSubtitle, getStoredMute, getStoredVolume, getStoredWebTorrentEnabled, saveAverageBandwidth, + saveLastSubtitle, saveMuteInStore, saveVolumeInStore } from './peertube-player-local-storage' @@ -67,10 +69,11 @@ class PeerTubePlugin extends Plugin { private currentVideoFile: VideoFile private torrent: WebTorrent.Torrent private videoCaptions: VideoJSCaption[] + private defaultSubtitle: string private renderer: any private fakeRenderer: any - private destoyingFakeRenderer = false + private destroyingFakeRenderer = false private autoResolution = true private forbidAutoResolution = false @@ -106,11 +109,34 @@ class PeerTubePlugin extends Plugin { if (this.autoplay === true) this.player.addClass('vjs-has-autoplay') this.player.ready(() => { + const playerOptions = this.player.options_ + const volume = getStoredVolume() if (volume !== undefined) this.player.volume(volume) - const muted = getStoredMute() + + const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() if (muted !== undefined) this.player.muted(muted) + this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() + + this.player.on('volumechange', () => { + saveVolumeInStore(this.player.volume()) + saveMuteInStore(this.player.muted()) + }) + + this.player.textTracks().on('change', () => { + const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { + return t.kind === 'captions' && t.mode === 'showing' + }) + + if (!showing) { + saveLastSubtitle('off') + return + } + + saveLastSubtitle(showing.language) + }) + this.player.duration(options.videoDuration) this.initializePlayer() @@ -124,11 +150,6 @@ class PeerTubePlugin extends Plugin { this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) }) }) - - this.player.on('volumechange', () => { - saveVolumeInStore(this.player.volume()) - saveMuteInStore(this.player.muted()) - }) } dispose () { @@ -657,14 +678,14 @@ class PeerTubePlugin extends Plugin { } private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { - this.destoyingFakeRenderer = false + this.destroyingFakeRenderer = false const fakeVideoElem = document.createElement('video') renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { this.fakeRenderer = renderer // The renderer returns an error when we destroy it, so skip them - if (this.destoyingFakeRenderer === false && err) { + if (this.destroyingFakeRenderer === false && err) { console.error('Cannot render new torrent in fake video element.', err) } @@ -675,7 +696,7 @@ class PeerTubePlugin extends Plugin { private destroyFakeRenderer () { if (this.fakeRenderer) { - this.destoyingFakeRenderer = true + this.destroyingFakeRenderer = true if (this.fakeRenderer.destroy) { try { @@ -695,7 +716,8 @@ class PeerTubePlugin extends Plugin { label: caption.label, language: caption.language, id: caption.language, - src: caption.src + src: caption.src, + default: this.defaultSubtitle === caption.language }, false) } } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index d127230fa..634c7fdc9 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -39,6 +39,7 @@ type PeertubePluginOptions = { autoplay: boolean, videoCaptions: VideoJSCaption[] + subtitle?: string userWatching?: UserWatching } diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index c87287482..8b9f34b99 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -39,6 +39,7 @@ function buildVideoLink (time?: number, url?: string) { } function timeToInt (time: number | string) { + if (!time) return 0 if (typeof time === 'number') return time const reg = /^((\d+)h)?((\d+)m)?((\d+)s?)?$/ -- cgit v1.2.3 From c32bf839c11557cac527ca5f181d1ce39fc80974 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 11 Jan 2019 16:44:45 +0100 Subject: Fix captions in HTTP fallback --- client/src/assets/player/peertube-player.ts | 8 ++++++++ client/src/assets/player/peertube-videojs-plugin.ts | 5 +++++ client/src/assets/player/settings-menu-item.ts | 13 +++++++++++++ 3 files changed, 26 insertions(+) (limited to 'client/src/assets') diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index e0e063838..2de6d7fef 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -254,6 +254,10 @@ function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) { loadLocaleInVideoJS.cache[path] = json return json }) + .catch(err => { + console.error('Cannot get player translations', err) + return undefined + }) } const completeLocale = getCompleteLocale(locale) @@ -270,6 +274,10 @@ function getServerTranslations (serverUrl: string, locale: string) { return fetch(path + '/server.json') .then(res => res.json()) + .catch(err => { + console.error('Cannot get server translations', err) + return undefined + }) } // ############################################################################ diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 4a280b7ef..e9fb90c61 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -620,6 +620,9 @@ class PeerTubePlugin extends Plugin { this.player.src = this.savePlayerSrcFunction this.player.src(httpUrl) + // We changed the source, so reinit captions + this.initCaptions() + return this.tryToPlay(err => { if (err && done) return done(err) @@ -720,6 +723,8 @@ class PeerTubePlugin extends Plugin { default: this.defaultSubtitle === caption.language }, false) } + + this.player.trigger('captionsChanged') } // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts index 698f4627a..2a3460ae5 100644 --- a/client/src/assets/player/settings-menu-item.ts +++ b/client/src/assets/player/settings-menu-item.ts @@ -48,6 +48,19 @@ class SettingsMenuItem extends MenuItem { // Update on rate change player.on('ratechange', this.submenuClickHandler) + if (subMenuName === 'CaptionsButton') { + // Hack to regenerate captions on HTTP fallback + player.on('captionsChanged', () => { + setTimeout(() => { + this.settingsSubMenuEl_.innerHTML = '' + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) + this.update() + this.bindClickEvents() + + }, 0) + }) + } + this.reset() }, 0) }) -- cgit v1.2.3 From 457bb213b273a9b206cc5654eb085cede4e916ad Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 16 Jan 2019 16:05:40 +0100 Subject: Refactor how we use icons Inject them in an angular component so we can easily change their color --- client/src/assets/images/global/add.html | 11 ++++ client/src/assets/images/global/add.svg | 13 ----- client/src/assets/images/global/alert.html | 11 ++++ client/src/assets/images/global/circle-tick.html | 12 +++++ .../src/assets/images/global/cloud-download.html | 11 ++++ client/src/assets/images/global/cloud-error.html | 11 ++++ client/src/assets/images/global/cog.html | 9 ++++ client/src/assets/images/global/cross.html | 10 ++++ client/src/assets/images/global/cross.svg | 12 ----- client/src/assets/images/global/delete-black.svg | 14 ------ client/src/assets/images/global/delete-grey.svg | 14 ------ client/src/assets/images/global/delete-white.svg | 14 ------ client/src/assets/images/global/delete.html | 12 +++++ client/src/assets/images/global/download.html | 11 ++++ client/src/assets/images/global/edit-black.svg | 15 ------ client/src/assets/images/global/edit-grey.svg | 15 ------ client/src/assets/images/global/edit.html | 10 ++++ client/src/assets/images/global/help.html | 10 ++++ client/src/assets/images/global/help.svg | 12 ----- client/src/assets/images/global/im-with-her.html | 10 ++++ client/src/assets/images/global/im-with-her.svg | 15 ------ client/src/assets/images/global/no.html | 10 ++++ client/src/assets/images/global/sparkle.html | 11 ++++ client/src/assets/images/global/syndication.html | 56 +++++++++++++++++++++ client/src/assets/images/global/syndication.svg | 58 ---------------------- client/src/assets/images/global/tick.html | 10 ++++ client/src/assets/images/global/tick.svg | 12 ----- client/src/assets/images/global/undo.html | 9 ++++ client/src/assets/images/global/undo.svg | 11 ---- client/src/assets/images/global/user-add.html | 11 ++++ client/src/assets/images/global/validate.html | 12 +++++ client/src/assets/images/global/validate.svg | 14 ------ client/src/assets/images/header/upload-white.svg | 16 ------ client/src/assets/images/video/alert.svg | 16 ------ client/src/assets/images/video/blacklist.svg | 15 ------ client/src/assets/images/video/dislike-grey.svg | 14 ------ client/src/assets/images/video/dislike-white.svg | 14 ------ client/src/assets/images/video/dislike.html | 12 +++++ client/src/assets/images/video/download-black.svg | 16 ------ client/src/assets/images/video/download-grey.svg | 16 ------ client/src/assets/images/video/download-white.svg | 16 ------ client/src/assets/images/video/heart.html | 11 ++++ client/src/assets/images/video/heart.svg | 13 ----- client/src/assets/images/video/like-grey.svg | 15 ------ client/src/assets/images/video/like-white.svg | 15 ------ client/src/assets/images/video/like.html | 10 ++++ client/src/assets/images/video/more.html | 9 ++++ client/src/assets/images/video/more.svg | 11 ---- client/src/assets/images/video/share.html | 11 ++++ client/src/assets/images/video/share.svg | 16 ------ client/src/assets/images/video/upload.html | 11 ++++ client/src/assets/images/video/upload.svg | 16 ------ 52 files changed, 311 insertions(+), 428 deletions(-) create mode 100644 client/src/assets/images/global/add.html delete mode 100644 client/src/assets/images/global/add.svg create mode 100644 client/src/assets/images/global/alert.html create mode 100644 client/src/assets/images/global/circle-tick.html create mode 100644 client/src/assets/images/global/cloud-download.html create mode 100644 client/src/assets/images/global/cloud-error.html create mode 100644 client/src/assets/images/global/cog.html create mode 100644 client/src/assets/images/global/cross.html delete mode 100644 client/src/assets/images/global/cross.svg delete mode 100644 client/src/assets/images/global/delete-black.svg delete mode 100644 client/src/assets/images/global/delete-grey.svg delete mode 100644 client/src/assets/images/global/delete-white.svg create mode 100644 client/src/assets/images/global/delete.html create mode 100644 client/src/assets/images/global/download.html delete mode 100644 client/src/assets/images/global/edit-black.svg delete mode 100644 client/src/assets/images/global/edit-grey.svg create mode 100644 client/src/assets/images/global/edit.html create mode 100644 client/src/assets/images/global/help.html delete mode 100644 client/src/assets/images/global/help.svg create mode 100644 client/src/assets/images/global/im-with-her.html delete mode 100644 client/src/assets/images/global/im-with-her.svg create mode 100644 client/src/assets/images/global/no.html create mode 100644 client/src/assets/images/global/sparkle.html create mode 100644 client/src/assets/images/global/syndication.html delete mode 100644 client/src/assets/images/global/syndication.svg create mode 100644 client/src/assets/images/global/tick.html delete mode 100644 client/src/assets/images/global/tick.svg create mode 100644 client/src/assets/images/global/undo.html delete mode 100644 client/src/assets/images/global/undo.svg create mode 100644 client/src/assets/images/global/user-add.html create mode 100644 client/src/assets/images/global/validate.html delete mode 100644 client/src/assets/images/global/validate.svg delete mode 100644 client/src/assets/images/header/upload-white.svg delete mode 100644 client/src/assets/images/video/alert.svg delete mode 100644 client/src/assets/images/video/blacklist.svg delete mode 100644 client/src/assets/images/video/dislike-grey.svg delete mode 100644 client/src/assets/images/video/dislike-white.svg create mode 100644 client/src/assets/images/video/dislike.html delete mode 100644 client/src/assets/images/video/download-black.svg delete mode 100644 client/src/assets/images/video/download-grey.svg delete mode 100644 client/src/assets/images/video/download-white.svg create mode 100644 client/src/assets/images/video/heart.html delete mode 100644 client/src/assets/images/video/heart.svg delete mode 100644 client/src/assets/images/video/like-grey.svg delete mode 100644 client/src/assets/images/video/like-white.svg create mode 100644 client/src/assets/images/video/like.html create mode 100644 client/src/assets/images/video/more.html delete mode 100644 client/src/assets/images/video/more.svg create mode 100644 client/src/assets/images/video/share.html delete mode 100644 client/src/assets/images/video/share.svg create mode 100644 client/src/assets/images/video/upload.html delete mode 100644 client/src/assets/images/video/upload.svg (limited to 'client/src/assets') diff --git a/client/src/assets/images/global/add.html b/client/src/assets/images/global/add.html new file mode 100644 index 000000000..bfb0a52bc --- /dev/null +++ b/client/src/assets/images/global/add.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/add.svg b/client/src/assets/images/global/add.svg deleted file mode 100644 index 42b269c43..000000000 --- a/client/src/assets/images/global/add.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client/src/assets/images/global/alert.html b/client/src/assets/images/global/alert.html new file mode 100644 index 000000000..7c8c02074 --- /dev/null +++ b/client/src/assets/images/global/alert.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/circle-tick.html b/client/src/assets/images/global/circle-tick.html new file mode 100644 index 000000000..2327de6be --- /dev/null +++ b/client/src/assets/images/global/circle-tick.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/src/assets/images/global/cloud-download.html b/client/src/assets/images/global/cloud-download.html new file mode 100644 index 000000000..b2634fd1f --- /dev/null +++ b/client/src/assets/images/global/cloud-download.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/cloud-error.html b/client/src/assets/images/global/cloud-error.html new file mode 100644 index 000000000..1a3483805 --- /dev/null +++ b/client/src/assets/images/global/cloud-error.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/cog.html b/client/src/assets/images/global/cog.html new file mode 100644 index 000000000..b74a180e7 --- /dev/null +++ b/client/src/assets/images/global/cog.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/src/assets/images/global/cross.html b/client/src/assets/images/global/cross.html new file mode 100644 index 000000000..962578487 --- /dev/null +++ b/client/src/assets/images/global/cross.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/global/cross.svg b/client/src/assets/images/global/cross.svg deleted file mode 100644 index d47a75996..000000000 --- a/client/src/assets/images/global/cross.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/client/src/assets/images/global/delete-black.svg b/client/src/assets/images/global/delete-black.svg deleted file mode 100644 index 04ddc23aa..000000000 --- a/client/src/assets/images/global/delete-black.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/assets/images/global/delete-grey.svg b/client/src/assets/images/global/delete-grey.svg deleted file mode 100644 index 67e9e2ce7..000000000 --- a/client/src/assets/images/global/delete-grey.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/assets/images/global/delete-white.svg b/client/src/assets/images/global/delete-white.svg deleted file mode 100644 index 9c52de557..000000000 --- a/client/src/assets/images/global/delete-white.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/assets/images/global/delete.html b/client/src/assets/images/global/delete.html new file mode 100644 index 000000000..a0d9a0cac --- /dev/null +++ b/client/src/assets/images/global/delete.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/src/assets/images/global/download.html b/client/src/assets/images/global/download.html new file mode 100644 index 000000000..259506f31 --- /dev/null +++ b/client/src/assets/images/global/download.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/edit-black.svg b/client/src/assets/images/global/edit-black.svg deleted file mode 100644 index 0176b0f37..000000000 --- a/client/src/assets/images/global/edit-black.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - edit - Created with Sketch. - - - - - - - - - - diff --git a/client/src/assets/images/global/edit-grey.svg b/client/src/assets/images/global/edit-grey.svg deleted file mode 100644 index 23ece68f1..000000000 --- a/client/src/assets/images/global/edit-grey.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - edit - Created with Sketch. - - - - - - - - - - diff --git a/client/src/assets/images/global/edit.html b/client/src/assets/images/global/edit.html new file mode 100644 index 000000000..f04183c2d --- /dev/null +++ b/client/src/assets/images/global/edit.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/global/help.html b/client/src/assets/images/global/help.html new file mode 100644 index 000000000..27e9bee6f --- /dev/null +++ b/client/src/assets/images/global/help.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/global/help.svg b/client/src/assets/images/global/help.svg deleted file mode 100644 index 48252febe..000000000 --- a/client/src/assets/images/global/help.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/client/src/assets/images/global/im-with-her.html b/client/src/assets/images/global/im-with-her.html new file mode 100644 index 000000000..de2c62e96 --- /dev/null +++ b/client/src/assets/images/global/im-with-her.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/global/im-with-her.svg b/client/src/assets/images/global/im-with-her.svg deleted file mode 100644 index 31d4754fd..000000000 --- a/client/src/assets/images/global/im-with-her.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - im-with-her - Created with Sketch. - - - - - - - - - - \ No newline at end of file diff --git a/client/src/assets/images/global/no.html b/client/src/assets/images/global/no.html new file mode 100644 index 000000000..bb7b28514 --- /dev/null +++ b/client/src/assets/images/global/no.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/global/sparkle.html b/client/src/assets/images/global/sparkle.html new file mode 100644 index 000000000..3b29fefb9 --- /dev/null +++ b/client/src/assets/images/global/sparkle.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/syndication.html b/client/src/assets/images/global/syndication.html new file mode 100644 index 000000000..e6c88a4db --- /dev/null +++ b/client/src/assets/images/global/syndication.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/assets/images/global/syndication.svg b/client/src/assets/images/global/syndication.svg deleted file mode 100644 index cb74cf81b..000000000 --- a/client/src/assets/images/global/syndication.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/src/assets/images/global/tick.html b/client/src/assets/images/global/tick.html new file mode 100644 index 000000000..4784b4807 --- /dev/null +++ b/client/src/assets/images/global/tick.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/global/tick.svg b/client/src/assets/images/global/tick.svg deleted file mode 100644 index 230caa111..000000000 --- a/client/src/assets/images/global/tick.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/client/src/assets/images/global/undo.html b/client/src/assets/images/global/undo.html new file mode 100644 index 000000000..228245c86 --- /dev/null +++ b/client/src/assets/images/global/undo.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/src/assets/images/global/undo.svg b/client/src/assets/images/global/undo.svg deleted file mode 100644 index f1cca03f7..000000000 --- a/client/src/assets/images/global/undo.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/client/src/assets/images/global/user-add.html b/client/src/assets/images/global/user-add.html new file mode 100644 index 000000000..57df23c74 --- /dev/null +++ b/client/src/assets/images/global/user-add.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/global/validate.html b/client/src/assets/images/global/validate.html new file mode 100644 index 000000000..520624ff6 --- /dev/null +++ b/client/src/assets/images/global/validate.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/src/assets/images/global/validate.svg b/client/src/assets/images/global/validate.svg deleted file mode 100644 index 5c7ee9d14..000000000 --- a/client/src/assets/images/global/validate.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/assets/images/header/upload-white.svg b/client/src/assets/images/header/upload-white.svg deleted file mode 100644 index 2b07caf76..000000000 --- a/client/src/assets/images/header/upload-white.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - cloud-upload - Created with Sketch. - - - - - - - - - - - diff --git a/client/src/assets/images/video/alert.svg b/client/src/assets/images/video/alert.svg deleted file mode 100644 index 5b43534ad..000000000 --- a/client/src/assets/images/video/alert.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - alert - Created with Sketch. - - - - - - - - - - - diff --git a/client/src/assets/images/video/blacklist.svg b/client/src/assets/images/video/blacklist.svg deleted file mode 100644 index 431c73816..000000000 --- a/client/src/assets/images/video/blacklist.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - no - Created with Sketch. - - - - - - - - - - diff --git a/client/src/assets/images/video/dislike-grey.svg b/client/src/assets/images/video/dislike-grey.svg deleted file mode 100644 index 56a7908fb..000000000 --- a/client/src/assets/images/video/dislike-grey.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/assets/images/video/dislike-white.svg b/client/src/assets/images/video/dislike-white.svg deleted file mode 100644 index cfc6eaa1f..000000000 --- a/client/src/assets/images/video/dislike-white.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/src/assets/images/video/dislike.html b/client/src/assets/images/video/dislike.html new file mode 100644 index 000000000..acde951e2 --- /dev/null +++ b/client/src/assets/images/video/dislike.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/src/assets/images/video/download-black.svg b/client/src/assets/images/video/download-black.svg deleted file mode 100644 index 501836746..000000000 --- a/client/src/assets/images/video/download-black.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - download - Created with Sketch. - - - - - - - - - - - diff --git a/client/src/assets/images/video/download-grey.svg b/client/src/assets/images/video/download-grey.svg deleted file mode 100644 index 5b0cca5ef..000000000 --- a/client/src/assets/images/video/download-grey.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - download - Created with Sketch. - - - - - - - - - - - diff --git a/client/src/assets/images/video/download-white.svg b/client/src/assets/images/video/download-white.svg deleted file mode 100644 index 0e66e06e8..000000000 --- a/client/src/assets/images/video/download-white.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - download - Created with Sketch. - - - - - - - - - - - diff --git a/client/src/assets/images/video/heart.html b/client/src/assets/images/video/heart.html new file mode 100644 index 000000000..618f64f10 --- /dev/null +++ b/client/src/assets/images/video/heart.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/video/heart.svg b/client/src/assets/images/video/heart.svg deleted file mode 100644 index 5d64aee0f..000000000 --- a/client/src/assets/images/video/heart.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/client/src/assets/images/video/like-grey.svg b/client/src/assets/images/video/like-grey.svg deleted file mode 100644 index 5ef6c7b31..000000000 --- a/client/src/assets/images/video/like-grey.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - thumbs-up - Created with Sketch. - - - - - - - - - - diff --git a/client/src/assets/images/video/like-white.svg b/client/src/assets/images/video/like-white.svg deleted file mode 100644 index 88e5f6a9a..000000000 --- a/client/src/assets/images/video/like-white.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - thumbs-up - Created with Sketch. - - - - - - - - - - diff --git a/client/src/assets/images/video/like.html b/client/src/assets/images/video/like.html new file mode 100644 index 000000000..d0e71763b --- /dev/null +++ b/client/src/assets/images/video/like.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/src/assets/images/video/more.html b/client/src/assets/images/video/more.html new file mode 100644 index 000000000..39dcad10e --- /dev/null +++ b/client/src/assets/images/video/more.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/src/assets/images/video/more.svg b/client/src/assets/images/video/more.svg deleted file mode 100644 index dea392136..000000000 --- a/client/src/assets/images/video/more.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/client/src/assets/images/video/share.html b/client/src/assets/images/video/share.html new file mode 100644 index 000000000..7759b37af --- /dev/null +++ b/client/src/assets/images/video/share.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/video/share.svg b/client/src/assets/images/video/share.svg deleted file mode 100644 index da0f43e81..000000000 --- a/client/src/assets/images/video/share.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - share - Created with Sketch. - - - - - - - - - - - diff --git a/client/src/assets/images/video/upload.html b/client/src/assets/images/video/upload.html new file mode 100644 index 000000000..3bc0d3a8a --- /dev/null +++ b/client/src/assets/images/video/upload.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/client/src/assets/images/video/upload.svg b/client/src/assets/images/video/upload.svg deleted file mode 100644 index c5b7cb443..000000000 --- a/client/src/assets/images/video/upload.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - cloud-upload - Created with Sketch. - - - - - - - - - - - -- cgit v1.2.3 From f2fab901df31a0e7081f4bb225f28e98798950b0 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 17 Jan 2019 11:30:47 +0100 Subject: Fix invisible things in dark mode --- client/src/assets/images/global/help.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/images/global/help.html b/client/src/assets/images/global/help.html index 27e9bee6f..80cd40321 100644 --- a/client/src/assets/images/global/help.html +++ b/client/src/assets/images/global/help.html @@ -2,8 +2,8 @@ - - + + -- cgit v1.2.3 From 2adfc7ea9a1f858db874df9fe322e7ae833db77c Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 23 Jan 2019 15:36:45 +0100 Subject: Refractor videojs player Add fake p2p-media-loader plugin --- .../src/assets/player/p2p-media-loader-plugin.ts | 33 + client/src/assets/player/peertube-chunk-store.ts | 231 ------- client/src/assets/player/peertube-link-button.ts | 40 -- .../assets/player/peertube-load-progress-bar.ts | 38 -- .../src/assets/player/peertube-player-manager.ts | 388 +++++++++++ client/src/assets/player/peertube-player.ts | 300 -------- client/src/assets/player/peertube-plugin.ts | 219 ++++++ .../src/assets/player/peertube-videojs-plugin.ts | 754 --------------------- .../src/assets/player/peertube-videojs-typings.ts | 67 +- client/src/assets/player/resolution-menu-button.ts | 88 --- client/src/assets/player/resolution-menu-item.ts | 67 -- client/src/assets/player/settings-menu-button.ts | 288 -------- client/src/assets/player/settings-menu-item.ts | 332 --------- client/src/assets/player/theater-button.ts | 50 -- client/src/assets/player/video-renderer.ts | 134 ---- .../player/videojs-components/p2p-info-button.ts | 102 +++ .../videojs-components/peertube-link-button.ts | 40 ++ .../peertube-load-progress-bar.ts | 38 ++ .../videojs-components/resolution-menu-button.ts | 84 +++ .../videojs-components/resolution-menu-item.ts | 87 +++ .../videojs-components/settings-menu-button.ts | 288 ++++++++ .../videojs-components/settings-menu-item.ts | 329 +++++++++ .../player/videojs-components/theater-button.ts | 50 ++ client/src/assets/player/webtorrent-info-button.ts | 102 --- client/src/assets/player/webtorrent-plugin.ts | 640 +++++++++++++++++ .../player/webtorrent/peertube-chunk-store.ts | 231 +++++++ .../src/assets/player/webtorrent/video-renderer.ts | 134 ++++ 27 files changed, 2721 insertions(+), 2433 deletions(-) create mode 100644 client/src/assets/player/p2p-media-loader-plugin.ts delete mode 100644 client/src/assets/player/peertube-chunk-store.ts delete mode 100644 client/src/assets/player/peertube-link-button.ts delete mode 100644 client/src/assets/player/peertube-load-progress-bar.ts create mode 100644 client/src/assets/player/peertube-player-manager.ts delete mode 100644 client/src/assets/player/peertube-player.ts create mode 100644 client/src/assets/player/peertube-plugin.ts delete mode 100644 client/src/assets/player/peertube-videojs-plugin.ts delete mode 100644 client/src/assets/player/resolution-menu-button.ts delete mode 100644 client/src/assets/player/resolution-menu-item.ts delete mode 100644 client/src/assets/player/settings-menu-button.ts delete mode 100644 client/src/assets/player/settings-menu-item.ts delete mode 100644 client/src/assets/player/theater-button.ts delete mode 100644 client/src/assets/player/video-renderer.ts create mode 100644 client/src/assets/player/videojs-components/p2p-info-button.ts create mode 100644 client/src/assets/player/videojs-components/peertube-link-button.ts create mode 100644 client/src/assets/player/videojs-components/peertube-load-progress-bar.ts create mode 100644 client/src/assets/player/videojs-components/resolution-menu-button.ts create mode 100644 client/src/assets/player/videojs-components/resolution-menu-item.ts create mode 100644 client/src/assets/player/videojs-components/settings-menu-button.ts create mode 100644 client/src/assets/player/videojs-components/settings-menu-item.ts create mode 100644 client/src/assets/player/videojs-components/theater-button.ts delete mode 100644 client/src/assets/player/webtorrent-info-button.ts create mode 100644 client/src/assets/player/webtorrent-plugin.ts create mode 100644 client/src/assets/player/webtorrent/peertube-chunk-store.ts create mode 100644 client/src/assets/player/webtorrent/video-renderer.ts (limited to 'client/src/assets') diff --git a/client/src/assets/player/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader-plugin.ts new file mode 100644 index 000000000..6d07a2c9c --- /dev/null +++ b/client/src/assets/player/p2p-media-loader-plugin.ts @@ -0,0 +1,33 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' +import { P2PMediaLoaderPluginOptions, VideoJSComponentInterface } from './peertube-videojs-typings' + +// videojs-hlsjs-plugin needs videojs in window +window['videojs'] = videojs +import '@streamroot/videojs-hlsjs-plugin' + +import { initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' + +// import { Events } from '../p2p-media-loader/p2p-media-loader-core/lib' + +const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') +class P2pMediaLoaderPlugin extends Plugin { + + constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { + super(player, options) + + initVideoJsContribHlsJsPlayer(player) + + console.log(options) + + player.src({ + type: options.type, + src: options.src + }) + } + +} + +videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) +export { P2pMediaLoaderPlugin } diff --git a/client/src/assets/player/peertube-chunk-store.ts b/client/src/assets/player/peertube-chunk-store.ts deleted file mode 100644 index 54cc0ea64..000000000 --- a/client/src/assets/player/peertube-chunk-store.ts +++ /dev/null @@ -1,231 +0,0 @@ -// From https://github.com/MinEduTDF/idb-chunk-store -// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues -// Thanks @santiagogil and @Feross - -import { EventEmitter } from 'events' -import Dexie from 'dexie' - -class ChunkDatabase extends Dexie { - chunks: Dexie.Table<{ id: number, buf: Buffer }, number> - - constructor (dbname: string) { - super(dbname) - - this.version(1).stores({ - chunks: 'id' - }) - } -} - -class ExpirationDatabase extends Dexie { - databases: Dexie.Table<{ name: string, expiration: number }, number> - - constructor () { - super('webtorrent-expiration') - - this.version(1).stores({ - databases: 'name,expiration' - }) - } -} - -export class PeertubeChunkStore extends EventEmitter { - private static readonly BUFFERING_PUT_MS = 1000 - private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute - private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes - - chunkLength: number - - private pendingPut: { id: number, buf: Buffer, cb: Function }[] = [] - // If the store is full - private memoryChunks: { [ id: number ]: Buffer | true } = {} - private databaseName: string - private putBulkTimeout: any - private cleanerInterval: any - private db: ChunkDatabase - private expirationDB: ExpirationDatabase - private readonly length: number - private readonly lastChunkLength: number - private readonly lastChunkIndex: number - - constructor (chunkLength: number, opts: any) { - super() - - this.databaseName = 'webtorrent-chunks-' - - if (!opts) opts = {} - if (opts.torrent && opts.torrent.infoHash) this.databaseName += opts.torrent.infoHash - else this.databaseName += '-default' - - this.setMaxListeners(100) - - this.chunkLength = Number(chunkLength) - if (!this.chunkLength) throw new Error('First argument must be a chunk length') - - this.length = Number(opts.length) || Infinity - - if (this.length !== Infinity) { - this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength - this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 - } - - this.db = new ChunkDatabase(this.databaseName) - // Track databases that expired - this.expirationDB = new ExpirationDatabase() - - this.runCleaner() - } - - put (index: number, buf: Buffer, cb: (err?: Error) => void) { - const isLastChunk = (index === this.lastChunkIndex) - if (isLastChunk && buf.length !== this.lastChunkLength) { - return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) - } - if (!isLastChunk && buf.length !== this.chunkLength) { - return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) - } - - // Specify we have this chunk - this.memoryChunks[index] = true - - // Add it to the pending put - this.pendingPut.push({ id: index, buf, cb }) - // If it's already planned, return - if (this.putBulkTimeout) return - - // Plan a future bulk insert - this.putBulkTimeout = setTimeout(async () => { - const processing = this.pendingPut - this.pendingPut = [] - this.putBulkTimeout = undefined - - try { - await this.db.transaction('rw', this.db.chunks, () => { - return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) - }) - } catch (err) { - console.log('Cannot bulk insert chunks. Store them in memory.', { err }) - - processing.forEach(p => this.memoryChunks[ p.id ] = p.buf) - } finally { - processing.forEach(p => p.cb()) - } - }, PeertubeChunkStore.BUFFERING_PUT_MS) - } - - get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { - if (typeof opts === 'function') return this.get(index, null, opts) - - // IndexDB could be slow, use our memory index first - const memoryChunk = this.memoryChunks[index] - if (memoryChunk === undefined) { - const err = new Error('Chunk not found') as any - err['notFound'] = true - - return process.nextTick(() => cb(err)) - } - - // Chunk in memory - if (memoryChunk !== true) return cb(null, memoryChunk) - - // Chunk in store - this.db.transaction('r', this.db.chunks, async () => { - const result = await this.db.chunks.get({ id: index }) - if (result === undefined) return cb(null, new Buffer(0)) - - const buf = result.buf - if (!opts) return this.nextTick(cb, null, buf) - - const offset = opts.offset || 0 - const len = opts.length || (buf.length - offset) - return cb(null, buf.slice(offset, len + offset)) - }) - .catch(err => { - console.error(err) - return cb(err) - }) - } - - close (cb: (err?: Error) => void) { - return this.destroy(cb) - } - - async destroy (cb: (err?: Error) => void) { - try { - if (this.pendingPut) { - clearTimeout(this.putBulkTimeout) - this.pendingPut = null - } - if (this.cleanerInterval) { - clearInterval(this.cleanerInterval) - this.cleanerInterval = null - } - - if (this.db) { - await this.db.close() - - await this.dropDatabase(this.databaseName) - } - - if (this.expirationDB) { - await this.expirationDB.close() - this.expirationDB = null - } - - return cb() - } catch (err) { - console.error('Cannot destroy peertube chunk store.', err) - return cb(err) - } - } - - private runCleaner () { - this.checkExpiration() - - this.cleanerInterval = setInterval(async () => { - this.checkExpiration() - }, PeertubeChunkStore.CLEANER_INTERVAL_MS) - } - - private async checkExpiration () { - let databasesToDeleteInfo: { name: string }[] = [] - - try { - await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { - // Update our database expiration since we are alive - await this.expirationDB.databases.put({ - name: this.databaseName, - expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS - }) - - const now = new Date().getTime() - databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() - }) - } catch (err) { - console.error('Cannot update expiration of fetch expired databases.', err) - } - - for (const databaseToDeleteInfo of databasesToDeleteInfo) { - await this.dropDatabase(databaseToDeleteInfo.name) - } - } - - private async dropDatabase (databaseName: string) { - const dbToDelete = new ChunkDatabase(databaseName) - console.log('Destroying IndexDB database %s.', databaseName) - - try { - await dbToDelete.delete() - - await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { - return this.expirationDB.databases.where({ name: databaseName }).delete() - }) - } catch (err) { - console.error('Cannot delete %s.', databaseName, err) - } - } - - private nextTick (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { - process.nextTick(() => cb(err, val), undefined) - } -} diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts deleted file mode 100644 index de9a49de9..000000000 --- a/client/src/assets/player/peertube-link-button.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -import { buildVideoLink } from './utils' -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import { Player } from 'video.js' - -const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') -class PeerTubeLinkButton extends Button { - - constructor (player: Player, options: any) { - super(player, options) - } - - createEl () { - return this.buildElement() - } - - updateHref () { - this.el().setAttribute('href', buildVideoLink(this.player().currentTime())) - } - - handleClick () { - this.player_.pause() - } - - private buildElement () { - const el = videojsUntyped.dom.createEl('a', { - href: buildVideoLink(), - innerHTML: 'PeerTube', - title: this.player_.localize('Go to the video page'), - className: 'vjs-peertube-link', - target: '_blank' - }) - - el.addEventListener('mouseenter', () => this.updateHref()) - - return el - } -} -Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) diff --git a/client/src/assets/player/peertube-load-progress-bar.ts b/client/src/assets/player/peertube-load-progress-bar.ts deleted file mode 100644 index af276d1b2..000000000 --- a/client/src/assets/player/peertube-load-progress-bar.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import { Player } from 'video.js' - -const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') - -class PeerTubeLoadProgressBar extends Component { - - constructor (player: Player, options: any) { - super(player, options) - this.partEls_ = [] - this.on(player, 'progress', this.update) - } - - createEl () { - return super.createEl('div', { - className: 'vjs-load-progress', - innerHTML: `${this.localize('Loaded')}: 0%` - }) - } - - dispose () { - this.partEls_ = null - - super.dispose() - } - - update () { - const torrent = this.player().peertube().getTorrent() - if (!torrent) return - - this.el_.style.width = (torrent.progress * 100) + '%' - } - -} - -Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts new file mode 100644 index 000000000..9155c0698 --- /dev/null +++ b/client/src/assets/player/peertube-player-manager.ts @@ -0,0 +1,388 @@ +import { VideoFile } from '../../../../shared/models/videos' +// @ts-ignore +import * as videojs from 'video.js' +import 'videojs-hotkeys' +import 'videojs-dock' +import 'videojs-contextmenu-ui' +import 'videojs-contrib-quality-levels' +import './peertube-plugin' +import './videojs-components/peertube-link-button' +import './videojs-components/resolution-menu-button' +import './videojs-components/settings-menu-button' +import './videojs-components/p2p-info-button' +import './videojs-components/peertube-load-progress-bar' +import './videojs-components/theater-button' +import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings' +import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' +import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' +import { Engine } from 'p2p-media-loader-hlsjs' + +// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) +videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' +// Change Captions to Subtitles/CC +videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC' +// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) +videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' + +type PlayerMode = 'webtorrent' | 'p2p-media-loader' + +type WebtorrentOptions = { + videoFiles: VideoFile[] +} + +type P2PMediaLoaderOptions = { + playlistUrl: string +} + +type CommonOptions = { + playerElement: HTMLVideoElement + + autoplay: boolean + videoDuration: number + enableHotkeys: boolean + inactivityTimeout: number + poster: string + startTime: number | string + + theaterMode: boolean + captions: boolean + peertubeLink: boolean + + videoViewUrl: string + embedUrl: string + + language?: string + controls?: boolean + muted?: boolean + loop?: boolean + subtitle?: string + + videoCaptions: VideoJSCaption[] + + userWatching?: UserWatching + + serverUrl: string +} + +export type PeertubePlayerManagerOptions = { + common: CommonOptions, + webtorrent?: WebtorrentOptions, + p2pMediaLoader?: P2PMediaLoaderOptions +} + +export class PeertubePlayerManager { + + private static videojsLocaleCache: { [ path: string ]: any } = {} + + static getServerTranslations (serverUrl: string, locale: string) { + const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) + // It is the default locale, nothing to translate + if (!path) return Promise.resolve(undefined) + + return fetch(path + '/server.json') + .then(res => res.json()) + .catch(err => { + console.error('Cannot get server translations', err) + return undefined + }) + } + + static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { + if (mode === 'webtorrent') await import('./webtorrent-plugin') + if (mode === 'p2p-media-loader') await import('./p2p-media-loader-plugin') + + const videojsOptions = this.getVideojsOptions(mode, options) + + await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language) + + const self = this + return new Promise(res => { + videojs(options.common.playerElement, videojsOptions, function (this: any) { + const player = this + + self.addContextMenu(mode, player, options.common.embedUrl) + + return res(player) + }) + }) + } + + private static loadLocaleInVideoJS (serverUrl: string, locale: string) { + const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) + // It is the default locale, nothing to translate + if (!path) return Promise.resolve(undefined) + + let p: Promise + + if (PeertubePlayerManager.videojsLocaleCache[path]) { + p = Promise.resolve(PeertubePlayerManager.videojsLocaleCache[path]) + } else { + p = fetch(path + '/player.json') + .then(res => res.json()) + .then(json => { + PeertubePlayerManager.videojsLocaleCache[path] = json + return json + }) + .catch(err => { + console.error('Cannot get player translations', err) + return undefined + }) + } + + const completeLocale = getCompleteLocale(locale) + return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json)) + } + + private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions) { + const commonOptions = options.common + const webtorrentOptions = options.webtorrent + const p2pMediaLoaderOptions = options.p2pMediaLoader + + const plugins: VideoJSPluginOptions = { + peertube: { + autoplay: commonOptions.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent + videoViewUrl: commonOptions.videoViewUrl, + videoDuration: commonOptions.videoDuration, + startTime: commonOptions.startTime, + userWatching: commonOptions.userWatching, + subtitle: commonOptions.subtitle, + videoCaptions: commonOptions.videoCaptions + } + } + + if (p2pMediaLoaderOptions) { + const p2pMediaLoader: P2PMediaLoaderPluginOptions = { + type: 'application/x-mpegURL', + src: p2pMediaLoaderOptions.playlistUrl + } + + const config = { + segments: { + swarmId: 'swarm' // TODO: choose swarm id + } + } + const streamrootHls = { + html5: { + hlsjsConfig: { + liveSyncDurationCount: 7, + loader: new Engine(config).createLoaderClass() + } + } + } + + Object.assign(plugins, { p2pMediaLoader, streamrootHls }) + } + + if (webtorrentOptions) { + const webtorrent = { + autoplay: commonOptions.autoplay, + videoDuration: commonOptions.videoDuration, + playerElement: commonOptions.playerElement, + videoFiles: webtorrentOptions.videoFiles + } + Object.assign(plugins, { webtorrent }) + } + + const videojsOptions = { + // We don't use text track settings for now + textTrackSettings: false, + controls: commonOptions.controls !== undefined ? commonOptions.controls : true, + loop: commonOptions.loop !== undefined ? commonOptions.loop : false, + + muted: commonOptions.muted !== undefined + ? commonOptions.muted + : undefined, // Undefined so the player knows it has to check the local storage + + poster: commonOptions.poster, + autoplay: false, + inactivityTimeout: commonOptions.inactivityTimeout, + playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], + plugins, + controlBar: { + children: this.getControlBarChildren(mode, { + captions: commonOptions.captions, + peertubeLink: commonOptions.peertubeLink, + theaterMode: commonOptions.theaterMode + }) + } + } + + if (commonOptions.enableHotkeys === true) { + Object.assign(videojsOptions.plugins, { + hotkeys: { + enableVolumeScroll: false, + enableModifiersForNumbers: false, + + fullscreenKey: function (event: KeyboardEvent) { + // fullscreen with the f key or Ctrl+Enter + return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') + }, + + seekStep: function (event: KeyboardEvent) { + // mimic VLC seek behavior, and default to 5 (original value is 5). + if (event.ctrlKey && event.altKey) { + return 5 * 60 + } else if (event.ctrlKey) { + return 60 + } else if (event.altKey) { + return 10 + } else { + return 5 + } + }, + + customKeys: { + increasePlaybackRateKey: { + key: function (event: KeyboardEvent) { + return event.key === '>' + }, + handler: function (player: videojs.Player) { + player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) + } + }, + decreasePlaybackRateKey: { + key: function (event: KeyboardEvent) { + return event.key === '<' + }, + handler: function (player: videojs.Player) { + player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) + } + }, + frameByFrame: { + key: function (event: KeyboardEvent) { + return event.key === '.' + }, + handler: function (player: videojs.Player) { + player.pause() + // Calculate movement distance (assuming 30 fps) + const dist = 1 / 30 + player.currentTime(player.currentTime() + dist) + } + } + } + } + }) + } + + if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { + Object.assign(videojsOptions, { language: commonOptions.language }) + } + + return videojsOptions + } + + private static getControlBarChildren (mode: PlayerMode, options: { + peertubeLink: boolean + theaterMode: boolean, + captions: boolean + }) { + const settingEntries = [] + const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar' + + // Keep an order + settingEntries.push('playbackRateMenuButton') + if (options.captions === true) settingEntries.push('captionsButton') + settingEntries.push('resolutionMenuButton') + + const children = { + 'playToggle': {}, + 'currentTimeDisplay': {}, + 'timeDivider': {}, + 'durationDisplay': {}, + 'liveDisplay': {}, + + 'flexibleWidthSpacer': {}, + 'progressControl': { + children: { + 'seekBar': { + children: { + [loadProgressBar]: {}, + 'mouseTimeDisplay': {}, + 'playProgressBar': {} + } + } + } + }, + + 'p2PInfoButton': {}, + + 'muteToggle': {}, + 'volumeControl': {}, + + 'settingsButton': { + setup: { + maxHeightOffset: 40 + }, + entries: settingEntries + } + } + + if (options.peertubeLink === true) { + Object.assign(children, { + 'peerTubeLinkButton': {} + }) + } + + if (options.theaterMode === true) { + Object.assign(children, { + 'theaterButton': {} + }) + } + + Object.assign(children, { + 'fullscreenToggle': {} + }) + + return children + } + + private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) { + const content = [ + { + label: player.localize('Copy the video URL'), + listener: function () { + copyToClipboard(buildVideoLink()) + } + }, + { + label: player.localize('Copy the video URL at the current time'), + listener: function () { + const player = this as videojs.Player + copyToClipboard(buildVideoLink(player.currentTime())) + } + }, + { + label: player.localize('Copy embed code'), + listener: () => { + copyToClipboard(buildVideoEmbed(videoEmbedUrl)) + } + } + ] + + if (mode === 'webtorrent') { + content.push({ + label: player.localize('Copy magnet URI'), + listener: function () { + const player = this as videojs.Player + copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri) + } + }) + } + + player.contextmenuUI({ content }) + } + + private static getLocalePath (serverUrl: string, locale: string) { + const completeLocale = getCompleteLocale(locale) + + if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined + + return serverUrl + '/client/locales/' + completeLocale + } +} + +// ############################################################################ + +export { + videojs +} diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts deleted file mode 100644 index 2de6d7fef..000000000 --- a/client/src/assets/player/peertube-player.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { VideoFile } from '../../../../shared/models/videos' - -import 'videojs-hotkeys' -import 'videojs-dock' -import 'videojs-contextmenu-ui' -import './peertube-link-button' -import './resolution-menu-button' -import './settings-menu-button' -import './webtorrent-info-button' -import './peertube-videojs-plugin' -import './peertube-load-progress-bar' -import './theater-button' -import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' -import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' -import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' - -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import { Player } from 'video.js' - -// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) -videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' -// Change Captions to Subtitles/CC -videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC' -// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) -videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' - -function getVideojsOptions (options: { - autoplay: boolean - playerElement: HTMLVideoElement - videoViewUrl: string - videoDuration: number - videoFiles: VideoFile[] - enableHotkeys: boolean - inactivityTimeout: number - peertubeLink: boolean - poster: string - startTime: number | string - theaterMode: boolean - videoCaptions: VideoJSCaption[] - - language?: string - controls?: boolean - muted?: boolean - loop?: boolean - subtitle?: string - - userWatching?: UserWatching -}) { - const videojsOptions = { - // We don't use text track settings for now - textTrackSettings: false, - controls: options.controls !== undefined ? options.controls : true, - loop: options.loop !== undefined ? options.loop : false, - - muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage - - poster: options.poster, - autoplay: false, - inactivityTimeout: options.inactivityTimeout, - playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], - plugins: { - peertube: { - autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent - videoCaptions: options.videoCaptions, - videoFiles: options.videoFiles, - playerElement: options.playerElement, - videoViewUrl: options.videoViewUrl, - videoDuration: options.videoDuration, - startTime: options.startTime, - userWatching: options.userWatching, - subtitle: options.subtitle - } - }, - controlBar: { - children: getControlBarChildren(options) - } - } - - if (options.enableHotkeys === true) { - Object.assign(videojsOptions.plugins, { - hotkeys: { - enableVolumeScroll: false, - enableModifiersForNumbers: false, - - fullscreenKey: function (event: KeyboardEvent) { - // fullscreen with the f key or Ctrl+Enter - return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') - }, - - seekStep: function (event: KeyboardEvent) { - // mimic VLC seek behavior, and default to 5 (original value is 5). - if (event.ctrlKey && event.altKey) { - return 5 * 60 - } else if (event.ctrlKey) { - return 60 - } else if (event.altKey) { - return 10 - } else { - return 5 - } - }, - - customKeys: { - increasePlaybackRateKey: { - key: function (event: KeyboardEvent) { - return event.key === '>' - }, - handler: function (player: Player) { - player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) - } - }, - decreasePlaybackRateKey: { - key: function (event: KeyboardEvent) { - return event.key === '<' - }, - handler: function (player: Player) { - player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) - } - }, - frameByFrame: { - key: function (event: KeyboardEvent) { - return event.key === '.' - }, - handler: function (player: Player) { - player.pause() - // Calculate movement distance (assuming 30 fps) - const dist = 1 / 30 - player.currentTime(player.currentTime() + dist) - } - } - } - } - }) - } - - if (options.language && !isDefaultLocale(options.language)) { - Object.assign(videojsOptions, { language: options.language }) - } - - return videojsOptions -} - -function getControlBarChildren (options: { - peertubeLink: boolean - theaterMode: boolean, - videoCaptions: VideoJSCaption[] -}) { - const settingEntries = [] - - // Keep an order - settingEntries.push('playbackRateMenuButton') - if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton') - settingEntries.push('resolutionMenuButton') - - const children = { - 'playToggle': {}, - 'currentTimeDisplay': {}, - 'timeDivider': {}, - 'durationDisplay': {}, - 'liveDisplay': {}, - - 'flexibleWidthSpacer': {}, - 'progressControl': { - children: { - 'seekBar': { - children: { - 'peerTubeLoadProgressBar': {}, - 'mouseTimeDisplay': {}, - 'playProgressBar': {} - } - } - } - }, - - 'webTorrentButton': {}, - - 'muteToggle': {}, - 'volumeControl': {}, - - 'settingsButton': { - setup: { - maxHeightOffset: 40 - }, - entries: settingEntries - } - } - - if (options.peertubeLink === true) { - Object.assign(children, { - 'peerTubeLinkButton': {} - }) - } - - if (options.theaterMode === true) { - Object.assign(children, { - 'theaterButton': {} - }) - } - - Object.assign(children, { - 'fullscreenToggle': {} - }) - - return children -} - -function addContextMenu (player: any, videoEmbedUrl: string) { - player.contextmenuUI({ - content: [ - { - label: player.localize('Copy the video URL'), - listener: function () { - copyToClipboard(buildVideoLink()) - } - }, - { - label: player.localize('Copy the video URL at the current time'), - listener: function () { - const player = this as Player - copyToClipboard(buildVideoLink(player.currentTime())) - } - }, - { - label: player.localize('Copy embed code'), - listener: () => { - copyToClipboard(buildVideoEmbed(videoEmbedUrl)) - } - }, - { - label: player.localize('Copy magnet URI'), - listener: function () { - const player = this as Player - copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri) - } - } - ] - }) -} - -function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) { - const path = getLocalePath(serverUrl, locale) - // It is the default locale, nothing to translate - if (!path) return Promise.resolve(undefined) - - let p: Promise - - if (loadLocaleInVideoJS.cache[path]) { - p = Promise.resolve(loadLocaleInVideoJS.cache[path]) - } else { - p = fetch(path + '/player.json') - .then(res => res.json()) - .then(json => { - loadLocaleInVideoJS.cache[path] = json - return json - }) - .catch(err => { - console.error('Cannot get player translations', err) - return undefined - }) - } - - const completeLocale = getCompleteLocale(locale) - return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json)) -} -namespace loadLocaleInVideoJS { - export const cache: { [ path: string ]: any } = {} -} - -function getServerTranslations (serverUrl: string, locale: string) { - const path = getLocalePath(serverUrl, locale) - // It is the default locale, nothing to translate - if (!path) return Promise.resolve(undefined) - - return fetch(path + '/server.json') - .then(res => res.json()) - .catch(err => { - console.error('Cannot get server translations', err) - return undefined - }) -} - -// ############################################################################ - -export { - getServerTranslations, - loadLocaleInVideoJS, - getVideojsOptions, - addContextMenu -} - -// ############################################################################ - -function getLocalePath (serverUrl: string, locale: string) { - const completeLocale = getCompleteLocale(locale) - - if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined - - return serverUrl + '/client/locales/' + completeLocale -} diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts new file mode 100644 index 000000000..0bd607697 --- /dev/null +++ b/client/src/assets/player/peertube-plugin.ts @@ -0,0 +1,219 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' +import './videojs-components/settings-menu-button' +import { PeerTubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +import { isMobile, timeToInt } from './utils' +import { + getStoredLastSubtitle, + getStoredMute, + getStoredVolume, + saveLastSubtitle, + saveMuteInStore, + saveVolumeInStore +} from './peertube-player-local-storage' + +const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') +class PeerTubePlugin extends Plugin { + private readonly autoplay: boolean = false + private readonly startTime: number = 0 + private readonly videoViewUrl: string + private readonly videoDuration: number + private readonly CONSTANTS = { + USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video + } + + private player: any + private videoCaptions: VideoJSCaption[] + private defaultSubtitle: string + + private videoViewInterval: any + private userWatchingVideoInterval: any + private qualityObservationTimer: any + + constructor (player: videojs.Player, options: PeerTubePluginOptions) { + super(player, options) + + this.startTime = timeToInt(options.startTime) + this.videoViewUrl = options.videoViewUrl + this.videoDuration = options.videoDuration + this.videoCaptions = options.videoCaptions + + if (this.autoplay === true) this.player.addClass('vjs-has-autoplay') + + this.player.ready(() => { + const playerOptions = this.player.options_ + + const volume = getStoredVolume() + if (volume !== undefined) this.player.volume(volume) + + const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() + if (muted !== undefined) this.player.muted(muted) + + this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() + + this.player.on('volumechange', () => { + saveVolumeInStore(this.player.volume()) + saveMuteInStore(this.player.muted()) + }) + + this.player.textTracks().on('change', () => { + const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { + return t.kind === 'captions' && t.mode === 'showing' + }) + + if (!showing) { + saveLastSubtitle('off') + return + } + + saveLastSubtitle(showing.language) + }) + + this.player.on('sourcechange', () => this.initCaptions()) + + this.player.duration(options.videoDuration) + + this.initializePlayer() + this.runViewAdd() + + if (options.userWatching) this.runUserWatchVideo(options.userWatching) + }) + } + + dispose () { + clearTimeout(this.qualityObservationTimer) + + clearInterval(this.videoViewInterval) + + if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) + } + + private initializePlayer () { + if (isMobile()) this.player.addClass('vjs-is-mobile') + + this.initSmoothProgressBar() + + this.initCaptions() + + this.alterInactivity() + } + + private runViewAdd () { + this.clearVideoViewInterval() + + // After 30 seconds (or 3/4 of the video), add a view to the video + let minSecondsToView = 30 + + if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4 + + let secondsViewed = 0 + this.videoViewInterval = setInterval(() => { + if (this.player && !this.player.paused()) { + secondsViewed += 1 + + if (secondsViewed > minSecondsToView) { + this.clearVideoViewInterval() + + this.addViewToVideo().catch(err => console.error(err)) + } + } + }, 1000) + } + + private runUserWatchVideo (options: UserWatching) { + let lastCurrentTime = 0 + + this.userWatchingVideoInterval = setInterval(() => { + const currentTime = Math.floor(this.player.currentTime()) + + if (currentTime - lastCurrentTime >= 1) { + lastCurrentTime = currentTime + + this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) + .catch(err => console.error('Cannot notify user is watching.', err)) + } + }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) + } + + private clearVideoViewInterval () { + if (this.videoViewInterval !== undefined) { + clearInterval(this.videoViewInterval) + this.videoViewInterval = undefined + } + } + + private addViewToVideo () { + if (!this.videoViewUrl) return Promise.resolve(undefined) + + return fetch(this.videoViewUrl, { method: 'POST' }) + } + + private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { + const body = new URLSearchParams() + body.append('currentTime', currentTime.toString()) + + const headers = new Headers({ 'Authorization': authorizationHeader }) + + return fetch(url, { method: 'PUT', body, headers }) + } + + private alterInactivity () { + let saveInactivityTimeout: number + + const disableInactivity = () => { + saveInactivityTimeout = this.player.options_.inactivityTimeout + this.player.options_.inactivityTimeout = 0 + } + const enableInactivity = () => { + this.player.options_.inactivityTimeout = saveInactivityTimeout + } + + const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog') + + this.player.controlBar.on('mouseenter', () => disableInactivity()) + settingsDialog.on('mouseenter', () => disableInactivity()) + this.player.controlBar.on('mouseleave', () => enableInactivity()) + settingsDialog.on('mouseleave', () => enableInactivity()) + } + + private initCaptions () { + for (const caption of this.videoCaptions) { + this.player.addRemoteTextTrack({ + kind: 'captions', + label: caption.label, + language: caption.language, + id: caption.language, + src: caption.src, + default: this.defaultSubtitle === caption.language + }, false) + } + + this.player.trigger('captionsChanged') + } + + // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 + private initSmoothProgressBar () { + const SeekBar = videojsUntyped.getComponent('SeekBar') + SeekBar.prototype.getPercent = function getPercent () { + // Allows for smooth scrubbing, when player can't keep up. + // const time = (this.player_.scrubbing()) ? + // this.player_.getCache().currentTime : + // this.player_.currentTime() + const time = this.player_.currentTime() + const percent = time / this.player_.duration() + return percent >= 1 ? 1 : percent + } + SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { + let newTime = this.calculateDistance(event) * this.player_.duration() + if (newTime === this.player_.duration()) { + newTime = newTime - 0.1 + } + this.player_.currentTime(newTime) + this.update() + } + } +} + +videojs.registerPlugin('peertube', PeerTubePlugin) +export { PeerTubePlugin } diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts deleted file mode 100644 index e9fb90c61..000000000 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ /dev/null @@ -1,754 +0,0 @@ -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import * as videojs from 'video.js' - -import * as WebTorrent from 'webtorrent' -import { VideoFile } from '../../../../shared/models/videos/video.model' -import { renderVideo } from './video-renderer' -import './settings-menu-button' -import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' -import { PeertubeChunkStore } from './peertube-chunk-store' -import { - getAverageBandwidthInStore, - getStoredLastSubtitle, - getStoredMute, - getStoredVolume, - getStoredWebTorrentEnabled, - saveAverageBandwidth, - saveLastSubtitle, - saveMuteInStore, - saveVolumeInStore -} from './peertube-player-local-storage' - -const CacheChunkStore = require('cache-chunk-store') - -type PlayOptions = { - forcePlay?: boolean, - seek?: number, - delay?: number -} - -const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') -class PeerTubePlugin extends Plugin { - private readonly playerElement: HTMLVideoElement - - private readonly autoplay: boolean = false - private readonly startTime: number = 0 - private readonly savePlayerSrcFunction: Function - private readonly videoFiles: VideoFile[] - private readonly videoViewUrl: string - private readonly videoDuration: number - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000, // Don't change this - AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds - AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it - AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check - AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds - BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth - USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video - } - - private readonly webtorrent = new WebTorrent({ - tracker: { - rtcConfig: { - iceServers: [ - { - urls: 'stun:stun.stunprotocol.org' - }, - { - urls: 'stun:stun.framasoft.org' - } - ] - } - }, - dht: false - }) - - private player: any - private currentVideoFile: VideoFile - private torrent: WebTorrent.Torrent - private videoCaptions: VideoJSCaption[] - private defaultSubtitle: string - - private renderer: any - private fakeRenderer: any - private destroyingFakeRenderer = false - - private autoResolution = true - private forbidAutoResolution = false - private isAutoResolutionObservation = false - private playerRefusedP2P = false - - private videoViewInterval: any - private torrentInfoInterval: any - private autoQualityInterval: any - private userWatchingVideoInterval: any - private addTorrentDelay: any - private qualityObservationTimer: any - private runAutoQualitySchedulerTimer: any - - private downloadSpeeds: number[] = [] - - constructor (player: videojs.Player, options: PeertubePluginOptions) { - super(player, options) - - // Disable auto play on iOS - this.autoplay = options.autoplay && this.isIOS() === false - this.playerRefusedP2P = !getStoredWebTorrentEnabled() - - this.startTime = timeToInt(options.startTime) - this.videoFiles = options.videoFiles - this.videoViewUrl = options.videoViewUrl - this.videoDuration = options.videoDuration - this.videoCaptions = options.videoCaptions - - this.savePlayerSrcFunction = this.player.src - this.playerElement = options.playerElement - - if (this.autoplay === true) this.player.addClass('vjs-has-autoplay') - - this.player.ready(() => { - const playerOptions = this.player.options_ - - const volume = getStoredVolume() - if (volume !== undefined) this.player.volume(volume) - - const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() - if (muted !== undefined) this.player.muted(muted) - - this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() - - this.player.on('volumechange', () => { - saveVolumeInStore(this.player.volume()) - saveMuteInStore(this.player.muted()) - }) - - this.player.textTracks().on('change', () => { - const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { - return t.kind === 'captions' && t.mode === 'showing' - }) - - if (!showing) { - saveLastSubtitle('off') - return - } - - saveLastSubtitle(showing.language) - }) - - this.player.duration(options.videoDuration) - - this.initializePlayer() - this.runTorrentInfoScheduler() - this.runViewAdd() - - if (options.userWatching) this.runUserWatchVideo(options.userWatching) - - this.player.one('play', () => { - // Don't run immediately scheduler, wait some seconds the TCP connections are made - this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - }) - }) - } - - dispose () { - clearTimeout(this.addTorrentDelay) - clearTimeout(this.qualityObservationTimer) - clearTimeout(this.runAutoQualitySchedulerTimer) - - clearInterval(this.videoViewInterval) - clearInterval(this.torrentInfoInterval) - clearInterval(this.autoQualityInterval) - - if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) - - // Don't need to destroy renderer, video player will be destroyed - this.flushVideoFile(this.currentVideoFile, false) - - this.destroyFakeRenderer() - } - - getCurrentResolutionId () { - return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 - } - - getCurrentResolutionLabel () { - if (!this.currentVideoFile) return '' - - const fps = this.currentVideoFile.fps >= 50 ? this.currentVideoFile.fps : '' - return this.currentVideoFile.resolution.label + fps - } - - updateVideoFile ( - videoFile?: VideoFile, - options: { - forcePlay?: boolean, - seek?: number, - delay?: number - } = {}, - done: () => void = () => { /* empty */ } - ) { - // Automatically choose the adapted video file - if (videoFile === undefined) { - const savedAverageBandwidth = getAverageBandwidthInStore() - videoFile = savedAverageBandwidth - ? this.getAppropriateFile(savedAverageBandwidth) - : this.pickAverageVideoFile() - } - - // Don't add the same video file once again - if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { - return - } - - // Do not display error to user because we will have multiple fallback - this.disableErrorDisplay() - - // Hack to "simulate" src link in video.js >= 6 - // Without this, we can't play the video after pausing it - // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 - this.player.src = () => true - const oldPlaybackRate = this.player.playbackRate() - - const previousVideoFile = this.currentVideoFile - this.currentVideoFile = videoFile - - // Don't try on iOS that does not support MediaSource - // Or don't use P2P if webtorrent is disabled - if (this.isIOS() || this.playerRefusedP2P) { - return this.fallbackToHttp(options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - } - - this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - - this.trigger('videoFileUpdate') - } - - updateResolution (resolutionId: number, delay = 0) { - // Remember player state - const currentTime = this.player.currentTime() - const isPaused = this.player.paused() - - // Remove poster to have black background - this.playerElement.poster = '' - - // Hide bigPlayButton - if (!isPaused) { - this.player.bigPlayButton.hide() - } - - const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) - const options = { - forcePlay: false, - delay, - seek: currentTime + (delay / 1000) - } - this.updateVideoFile(newVideoFile, options) - } - - flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { - if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { - if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() - - this.webtorrent.remove(videoFile.magnetUri) - console.log('Removed ' + videoFile.magnetUri) - } - } - - isAutoResolutionOn () { - return this.autoResolution - } - - enableAutoResolution () { - this.autoResolution = true - this.trigger('autoResolutionUpdate') - } - - disableAutoResolution (forbid = false) { - if (forbid === true) this.forbidAutoResolution = true - - this.autoResolution = false - this.trigger('autoResolutionUpdate') - } - - isAutoResolutionForbidden () { - return this.forbidAutoResolution === true - } - - getCurrentVideoFile () { - return this.currentVideoFile - } - - getTorrent () { - return this.torrent - } - - private addTorrent ( - magnetOrTorrentUrl: string, - previousVideoFile: VideoFile, - options: PlayOptions, - done: Function - ) { - console.log('Adding ' + magnetOrTorrentUrl + '.') - - const oldTorrent = this.torrent - const torrentOptions = { - store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { - max: 100 - }) - } - - this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { - console.log('Added ' + magnetOrTorrentUrl + '.') - - if (oldTorrent) { - // Pause the old torrent - this.stopTorrent(oldTorrent) - - // We use a fake renderer so we download correct pieces of the next file - if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay) - } - - // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) - this.addTorrentDelay = setTimeout(() => { - // We don't need the fake renderer anymore - this.destroyFakeRenderer() - - const paused = this.player.paused() - - this.flushVideoFile(previousVideoFile) - - // Update progress bar (just for the UI), do not wait rendering - if (options.seek) this.player.currentTime(options.seek) - - const renderVideoOptions = { autoplay: false, controls: true } - renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { - this.renderer = renderer - - if (err) return this.fallbackToHttp(options, done) - - return this.tryToPlay(err => { - if (err) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - return done() - }) - }) - }, options.delay || 0) - }) - - this.torrent.on('error', (err: any) => console.error(err)) - - this.torrent.on('warning', (err: any) => { - // We don't support HTTP tracker but we don't care -> we use the web socket tracker - if (err.message.indexOf('Unsupported tracker protocol') !== -1) return - - // Users don't care about issues with WebRTC, but developers do so log it in the console - if (err.message.indexOf('Ice connection failed') !== -1) { - console.log(err) - return - } - - // Magnet hash is not up to date with the torrent file, add directly the torrent file - if (err.message.indexOf('incorrect info hash') !== -1) { - console.error('Incorrect info hash detected, falling back to torrent file.') - const newOptions = { forcePlay: true, seek: options.seek } - return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done) - } - - // Remote instance is down - if (err.message.indexOf('from xs param') !== -1) { - this.handleError(err) - } - - console.warn(err) - }) - } - - private tryToPlay (done?: (err?: Error) => void) { - if (!done) done = function () { /* empty */ } - - const playPromise = this.player.play() - if (playPromise !== undefined) { - return playPromise.then(done) - .catch((err: Error) => { - if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) { - return - } - - console.error(err) - this.player.pause() - this.player.posterImage.show() - this.player.removeClass('vjs-has-autoplay') - this.player.removeClass('vjs-has-big-play-button-clicked') - - return done() - }) - } - - return done() - } - - private seek (time: number) { - this.player.currentTime(time) - this.player.handleTechSeeked_() - } - - private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { - if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined - if (this.videoFiles.length === 1) return this.videoFiles[0] - - // Don't change the torrent is the play was ended - if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile - - if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() - - // Limit resolution according to player height - const playerHeight = this.playerElement.offsetHeight as number - - // We take the first resolution just above the player height - // Example: player height is 530px, we want the 720p file instead of 480p - let maxResolution = this.videoFiles[0].resolution.id - for (let i = this.videoFiles.length - 1; i >= 0; i--) { - const resolutionId = this.videoFiles[i].resolution.id - if (resolutionId >= playerHeight) { - maxResolution = resolutionId - break - } - } - - // Filter videos we can play according to our screen resolution and bandwidth - const filteredFiles = this.videoFiles - .filter(f => f.resolution.id <= maxResolution) - .filter(f => { - const fileBitrate = (f.size / this.videoDuration) - let threshold = fileBitrate - - // If this is for a higher resolution or an initial load: add a margin - if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { - threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) - } - - return averageDownloadSpeed > threshold - }) - - // If the download speed is too bad, return the lowest resolution we have - if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles) - - return videoFileMaxByResolution(filteredFiles) - } - - private getAndSaveActualDownloadSpeed () { - const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) - const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) - if (lastDownloadSpeeds.length === 0) return -1 - - const sum = lastDownloadSpeeds.reduce((a, b) => a + b) - const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) - - // Save the average bandwidth for future use - saveAverageBandwidth(averageBandwidth) - - return averageBandwidth - } - - private initializePlayer () { - if (isMobile()) this.player.addClass('vjs-is-mobile') - - this.initSmoothProgressBar() - - this.initCaptions() - - this.alterInactivity() - - if (this.autoplay === true) { - this.player.posterImage.hide() - - return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - - // Proxy first play - const oldPlay = this.player.play.bind(this.player) - this.player.play = () => { - this.player.addClass('vjs-has-big-play-button-clicked') - this.player.play = oldPlay - - this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - } - - private runAutoQualityScheduler () { - this.autoQualityInterval = setInterval(() => { - - // Not initialized or in HTTP fallback - if (this.torrent === undefined || this.torrent === null) return - if (this.isAutoResolutionOn() === false) return - if (this.isAutoResolutionObservation === true) return - - const file = this.getAppropriateFile() - let changeResolution = false - let changeResolutionDelay = 0 - - // Lower resolution - if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { - console.log('Downgrading automatically the resolution to: %s', file.resolution.label) - changeResolution = true - } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution - console.log('Upgrading automatically the resolution to: %s', file.resolution.label) - changeResolution = true - changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY - } - - if (changeResolution === true) { - this.updateResolution(file.resolution.id, changeResolutionDelay) - - // Wait some seconds in observation of our new resolution - this.isAutoResolutionObservation = true - - this.qualityObservationTimer = setTimeout(() => { - this.isAutoResolutionObservation = false - }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) - } - }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - } - - private isPlayerWaiting () { - return this.player && this.player.hasClass('vjs-waiting') - } - - private runTorrentInfoScheduler () { - this.torrentInfoInterval = setInterval(() => { - // Not initialized yet - if (this.torrent === undefined) return - - // Http fallback - if (this.torrent === null) return this.trigger('torrentInfo', false) - - // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too - if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) - - return this.trigger('torrentInfo', { - downloadSpeed: this.torrent.downloadSpeed, - numPeers: this.torrent.numPeers, - uploadSpeed: this.torrent.uploadSpeed, - downloaded: this.torrent.downloaded, - uploaded: this.torrent.uploaded - }) - }, this.CONSTANTS.INFO_SCHEDULER) - } - - private runViewAdd () { - this.clearVideoViewInterval() - - // After 30 seconds (or 3/4 of the video), add a view to the video - let minSecondsToView = 30 - - if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4 - - let secondsViewed = 0 - this.videoViewInterval = setInterval(() => { - if (this.player && !this.player.paused()) { - secondsViewed += 1 - - if (secondsViewed > minSecondsToView) { - this.clearVideoViewInterval() - - this.addViewToVideo().catch(err => console.error(err)) - } - } - }, 1000) - } - - private runUserWatchVideo (options: UserWatching) { - let lastCurrentTime = 0 - - this.userWatchingVideoInterval = setInterval(() => { - const currentTime = Math.floor(this.player.currentTime()) - - if (currentTime - lastCurrentTime >= 1) { - lastCurrentTime = currentTime - - this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) - .catch(err => console.error('Cannot notify user is watching.', err)) - } - }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) - } - - private clearVideoViewInterval () { - if (this.videoViewInterval !== undefined) { - clearInterval(this.videoViewInterval) - this.videoViewInterval = undefined - } - } - - private addViewToVideo () { - if (!this.videoViewUrl) return Promise.resolve(undefined) - - return fetch(this.videoViewUrl, { method: 'POST' }) - } - - private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { - const body = new URLSearchParams() - body.append('currentTime', currentTime.toString()) - - const headers = new Headers({ 'Authorization': authorizationHeader }) - - return fetch(url, { method: 'PUT', body, headers }) - } - - private fallbackToHttp (options: PlayOptions, done?: Function) { - const paused = this.player.paused() - - this.disableAutoResolution(true) - - this.flushVideoFile(this.currentVideoFile, true) - this.torrent = null - - // Enable error display now this is our last fallback - this.player.one('error', () => this.enableErrorDisplay()) - - const httpUrl = this.currentVideoFile.fileUrl - this.player.src = this.savePlayerSrcFunction - this.player.src(httpUrl) - - // We changed the source, so reinit captions - this.initCaptions() - - return this.tryToPlay(err => { - if (err && done) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - if (done) return done() - }) - } - - private handleError (err: Error | string) { - return this.player.trigger('customError', { err }) - } - - private enableErrorDisplay () { - this.player.addClass('vjs-error-display-enabled') - } - - private disableErrorDisplay () { - this.player.removeClass('vjs-error-display-enabled') - } - - private isIOS () { - return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) - } - - private alterInactivity () { - let saveInactivityTimeout: number - - const disableInactivity = () => { - saveInactivityTimeout = this.player.options_.inactivityTimeout - this.player.options_.inactivityTimeout = 0 - } - const enableInactivity = () => { - this.player.options_.inactivityTimeout = saveInactivityTimeout - } - - const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog') - - this.player.controlBar.on('mouseenter', () => disableInactivity()) - settingsDialog.on('mouseenter', () => disableInactivity()) - this.player.controlBar.on('mouseleave', () => enableInactivity()) - settingsDialog.on('mouseleave', () => enableInactivity()) - } - - private pickAverageVideoFile () { - if (this.videoFiles.length === 1) return this.videoFiles[0] - - return this.videoFiles[Math.floor(this.videoFiles.length / 2)] - } - - private stopTorrent (torrent: WebTorrent.Torrent) { - torrent.pause() - // Pause does not remove actual peers (in particular the webseed peer) - torrent.removePeer(torrent[ 'ws' ]) - } - - private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { - this.destroyingFakeRenderer = false - - const fakeVideoElem = document.createElement('video') - renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { - this.fakeRenderer = renderer - - // The renderer returns an error when we destroy it, so skip them - if (this.destroyingFakeRenderer === false && err) { - console.error('Cannot render new torrent in fake video element.', err) - } - - // Load the future file at the correct time (in delay MS - 2 seconds) - fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) - }) - } - - private destroyFakeRenderer () { - if (this.fakeRenderer) { - this.destroyingFakeRenderer = true - - if (this.fakeRenderer.destroy) { - try { - this.fakeRenderer.destroy() - } catch (err) { - console.log('Cannot destroy correctly fake renderer.', err) - } - } - this.fakeRenderer = undefined - } - } - - private initCaptions () { - for (const caption of this.videoCaptions) { - this.player.addRemoteTextTrack({ - kind: 'captions', - label: caption.label, - language: caption.language, - id: caption.language, - src: caption.src, - default: this.defaultSubtitle === caption.language - }, false) - } - - this.player.trigger('captionsChanged') - } - - // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 - private initSmoothProgressBar () { - const SeekBar = videojsUntyped.getComponent('SeekBar') - SeekBar.prototype.getPercent = function getPercent () { - // Allows for smooth scrubbing, when player can't keep up. - // const time = (this.player_.scrubbing()) ? - // this.player_.getCache().currentTime : - // this.player_.currentTime() - const time = this.player_.currentTime() - const percent = time / this.player_.duration() - return percent >= 1 ? 1 : percent - } - SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { - let newTime = this.calculateDistance(event) * this.player_.duration() - if (newTime === this.player_.duration()) { - newTime = newTime - 0.1 - } - this.player_.currentTime(newTime) - this.update() - } - } -} - -videojs.registerPlugin('peertube', PeerTubePlugin) -export { PeerTubePlugin } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 634c7fdc9..060ea4dce 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -3,11 +3,13 @@ import * as videojs from 'video.js' import { VideoFile } from '../../../../shared/models/videos/video.model' -import { PeerTubePlugin } from './peertube-videojs-plugin' +import { PeerTubePlugin } from './peertube-plugin' +import { WebTorrentPlugin } from './webtorrent-plugin' declare namespace videojs { interface Player { peertube (): PeerTubePlugin + webtorrent (): WebTorrentPlugin } } @@ -30,26 +32,73 @@ type UserWatching = { authorizationHeader: string } -type PeertubePluginOptions = { - videoFiles: VideoFile[] - playerElement: HTMLVideoElement +type PeerTubePluginOptions = { + autoplay: boolean videoViewUrl: string videoDuration: number startTime: number | string - autoplay: boolean, - videoCaptions: VideoJSCaption[] - subtitle?: string userWatching?: UserWatching + subtitle?: string + + videoCaptions: VideoJSCaption[] +} + +type WebtorrentPluginOptions = { + playerElement: HTMLVideoElement + + autoplay: boolean + videoDuration: number + + videoFiles: VideoFile[] +} + +type P2PMediaLoaderPluginOptions = { + type: string + src: string +} + +type VideoJSPluginOptions = { + peertube: PeerTubePluginOptions + + webtorrent?: WebtorrentPluginOptions + + p2pMediaLoader?: P2PMediaLoaderPluginOptions } // videojs typings don't have some method we need const videojsUntyped = videojs as any +type LoadedQualityData = { + qualitySwitchCallback: Function, + qualityData: { + video: { + id: number + label: string + selected: boolean + }[] + } +} + +type ResolutionUpdateData = { + auto: boolean, + resolutionId: number +} + +type AutoResolutionUpdateData = { + possible: boolean +} + export { + ResolutionUpdateData, + AutoResolutionUpdateData, VideoJSComponentInterface, - PeertubePluginOptions, videojsUntyped, VideoJSCaption, - UserWatching + UserWatching, + PeerTubePluginOptions, + WebtorrentPluginOptions, + P2PMediaLoaderPluginOptions, + VideoJSPluginOptions, + LoadedQualityData } diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts deleted file mode 100644 index a3c1108ca..000000000 --- a/client/src/assets/player/resolution-menu-button.ts +++ /dev/null @@ -1,88 +0,0 @@ -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import { Player } from 'video.js' - -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -import { ResolutionMenuItem } from './resolution-menu-item' - -const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') -const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') -class ResolutionMenuButton extends MenuButton { - label: HTMLElement - - constructor (player: Player, options: any) { - super(player, options) - this.player = player - - player.peertube().on('videoFileUpdate', () => this.updateLabel()) - player.peertube().on('autoResolutionUpdate', () => this.updateLabel()) - } - - createEl () { - const el = super.createEl() - - this.labelEl_ = videojsUntyped.dom.createEl('div', { - className: 'vjs-resolution-value', - innerHTML: this.buildLabelHTML() - }) - - el.appendChild(this.labelEl_) - - return el - } - - updateARIAAttributes () { - this.el().setAttribute('aria-label', 'Quality') - } - - createMenu () { - const menu = new Menu(this.player_) - for (const videoFile of this.player_.peertube().videoFiles) { - let label = videoFile.resolution.label - if (videoFile.fps && videoFile.fps >= 50) { - label += videoFile.fps - } - - menu.addChild(new ResolutionMenuItem( - this.player_, - { - id: videoFile.resolution.id, - label, - src: videoFile.magnetUri - }) - ) - } - - menu.addChild(new ResolutionMenuItem( - this.player_, - { - id: -1, - label: this.player_.localize('Auto'), - src: null - } - )) - - return menu - } - - updateLabel () { - if (!this.labelEl_) return - - this.labelEl_.innerHTML = this.buildLabelHTML() - } - - buildCSSClass () { - return super.buildCSSClass() + ' vjs-resolution-button' - } - - buildWrapperCSSClass () { - return 'vjs-resolution-control ' + super.buildWrapperCSSClass() - } - - private buildLabelHTML () { - return this.player_.peertube().getCurrentResolutionLabel() - } -} -ResolutionMenuButton.prototype.controlText_ = 'Quality' - -MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts deleted file mode 100644 index b54fd91ef..000000000 --- a/client/src/assets/player/resolution-menu-item.ts +++ /dev/null @@ -1,67 +0,0 @@ -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import { Player } from 'video.js' - -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' - -const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') -class ResolutionMenuItem extends MenuItem { - - constructor (player: Player, options: any) { - const currentResolutionId = player.peertube().getCurrentResolutionId() - options.selectable = true - options.selected = options.id === currentResolutionId - - super(player, options) - - this.label = options.label - this.id = options.id - - player.peertube().on('videoFileUpdate', () => this.updateSelection()) - player.peertube().on('autoResolutionUpdate', () => this.updateSelection()) - } - - handleClick (event: any) { - if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return - - super.handleClick(event) - - // Auto resolution - if (this.id === -1) { - this.player_.peertube().enableAutoResolution() - return - } - - this.player_.peertube().disableAutoResolution() - this.player_.peertube().updateResolution(this.id) - } - - updateSelection () { - // Check if auto resolution is forbidden or not - if (this.id === -1) { - if (this.player_.peertube().isAutoResolutionForbidden()) { - this.addClass('disabled') - } else { - this.removeClass('disabled') - } - } - - if (this.player_.peertube().isAutoResolutionOn()) { - this.selected(this.id === -1) - return - } - - this.selected(this.player_.peertube().getCurrentResolutionId() === this.id) - } - - getLabel () { - if (this.id === -1) { - return this.label + ' ' + this.player_.peertube().getCurrentResolutionLabel() + '' - } - - return this.label - } -} -MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) - -export { ResolutionMenuItem } diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts deleted file mode 100644 index a7aefdcc3..000000000 --- a/client/src/assets/player/settings-menu-button.ts +++ /dev/null @@ -1,288 +0,0 @@ -// Author: Yanko Shterev -// Thanks https://github.com/yshterev/videojs-settings-menu - -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import * as videojs from 'video.js' - -import { SettingsMenuItem } from './settings-menu-item' -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -import { toTitleCase } from './utils' - -const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') -const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') -const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') - -class SettingsButton extends Button { - constructor (player: videojs.Player, options: any) { - super(player, options) - - this.playerComponent = player - this.dialog = this.playerComponent.addChild('settingsDialog') - this.dialogEl = this.dialog.el_ - this.menu = null - this.panel = this.dialog.addChild('settingsPanel') - this.panelChild = this.panel.addChild('settingsPanelChild') - - this.addClass('vjs-settings') - this.el_.setAttribute('aria-label', 'Settings Button') - - // Event handlers - this.addSettingsItemHandler = this.onAddSettingsItem.bind(this) - this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this) - this.playerClickHandler = this.onPlayerClick.bind(this) - this.userInactiveHandler = this.onUserInactive.bind(this) - - this.buildMenu() - this.bindEvents() - - // Prepare the dialog - this.player().one('play', () => this.hideDialog()) - } - - onPlayerClick (event: MouseEvent) { - const element = event.target as HTMLElement - if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) { - return - } - - if (!this.dialog.hasClass('vjs-hidden')) { - this.hideDialog() - } - } - - onDisposeSettingsItem (event: any, name: string) { - if (name === undefined) { - let children = this.menu.children() - - while (children.length > 0) { - children[0].dispose() - this.menu.removeChild(children[0]) - } - - this.addClass('vjs-hidden') - } else { - let item = this.menu.getChild(name) - - if (item) { - item.dispose() - this.menu.removeChild(item) - } - } - - this.hideDialog() - - if (this.options_.entries.length === 0) { - this.addClass('vjs-hidden') - } - } - - onAddSettingsItem (event: any, data: any) { - const [ entry, options ] = data - - this.addMenuItem(entry, options) - this.removeClass('vjs-hidden') - } - - onUserInactive () { - if (!this.dialog.hasClass('vjs-hidden')) { - this.hideDialog() - } - } - - bindEvents () { - this.playerComponent.on('click', this.playerClickHandler) - this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler) - this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler) - this.playerComponent.on('userinactive', this.userInactiveHandler) - } - - buildCSSClass () { - return `vjs-icon-settings ${super.buildCSSClass()}` - } - - handleClick () { - if (this.dialog.hasClass('vjs-hidden')) { - this.showDialog() - } else { - this.hideDialog() - } - } - - showDialog () { - this.menu.el_.style.opacity = '1' - this.dialog.show() - - this.setDialogSize(this.getComponentSize(this.menu)) - } - - hideDialog () { - this.dialog.hide() - this.setDialogSize(this.getComponentSize(this.menu)) - this.menu.el_.style.opacity = '1' - this.resetChildren() - } - - getComponentSize (element: any) { - let width: number = null - let height: number = null - - // Could be component or just DOM element - if (element instanceof Component) { - width = element.el_.offsetWidth - height = element.el_.offsetHeight - - // keep width/height as properties for direct use - element.width = width - element.height = height - } else { - width = element.offsetWidth - height = element.offsetHeight - } - - return [ width, height ] - } - - setDialogSize ([ width, height ]: number[]) { - if (typeof height !== 'number') { - return - } - - let offset = this.options_.setup.maxHeightOffset - let maxHeight = this.playerComponent.el_.offsetHeight - offset - - if (height > maxHeight) { - height = maxHeight - width += 17 - this.panel.el_.style.maxHeight = `${height}px` - } else if (this.panel.el_.style.maxHeight !== '') { - this.panel.el_.style.maxHeight = '' - } - - this.dialogEl.style.width = `${width}px` - this.dialogEl.style.height = `${height}px` - } - - buildMenu () { - this.menu = new Menu(this.player()) - this.menu.addClass('vjs-main-menu') - let entries = this.options_.entries - - if (entries.length === 0) { - this.addClass('vjs-hidden') - this.panelChild.addChild(this.menu) - return - } - - for (let entry of entries) { - this.addMenuItem(entry, this.options_) - } - - this.panelChild.addChild(this.menu) - } - - addMenuItem (entry: any, options: any) { - const openSubMenu = function (this: any) { - if (videojsUntyped.dom.hasClass(this.el_, 'open')) { - videojsUntyped.dom.removeClass(this.el_, 'open') - } else { - videojsUntyped.dom.addClass(this.el_, 'open') - } - } - - options.name = toTitleCase(entry) - let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any) - - this.menu.addChild(settingsMenuItem) - - // Hide children to avoid sub menus stacking on top of each other - // or having multiple menus open - settingsMenuItem.on('click', videojs.bind(this, this.hideChildren)) - - // Whether to add or remove selected class on the settings sub menu element - settingsMenuItem.on('click', openSubMenu) - } - - resetChildren () { - for (let menuChild of this.menu.children()) { - menuChild.reset() - } - } - - /** - * Hide all the sub menus - */ - hideChildren () { - for (let menuChild of this.menu.children()) { - menuChild.hideSubMenu() - } - } - -} - -class SettingsPanel extends Component { - constructor (player: videojs.Player, options: any) { - super(player, options) - } - - createEl () { - return super.createEl('div', { - className: 'vjs-settings-panel', - innerHTML: '', - tabIndex: -1 - }) - } -} - -class SettingsPanelChild extends Component { - constructor (player: videojs.Player, options: any) { - super(player, options) - } - - createEl () { - return super.createEl('div', { - className: 'vjs-settings-panel-child', - innerHTML: '', - tabIndex: -1 - }) - } -} - -class SettingsDialog extends Component { - constructor (player: videojs.Player, options: any) { - super(player, options) - this.hide() - } - - /** - * Create the component's DOM element - * - * @return {Element} - * @method createEl - */ - createEl () { - const uniqueId = this.id_ - const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId - const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId - - return super.createEl('div', { - className: 'vjs-settings-dialog vjs-modal-overlay', - innerHTML: '', - tabIndex: -1 - }, { - 'role': 'dialog', - 'aria-labelledby': dialogLabelId, - 'aria-describedby': dialogDescriptionId - }) - } - -} - -SettingsButton.prototype.controlText_ = 'Settings' - -Component.registerComponent('SettingsButton', SettingsButton) -Component.registerComponent('SettingsDialog', SettingsDialog) -Component.registerComponent('SettingsPanel', SettingsPanel) -Component.registerComponent('SettingsPanelChild', SettingsPanelChild) - -export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild } diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts deleted file mode 100644 index 2a3460ae5..000000000 --- a/client/src/assets/player/settings-menu-item.ts +++ /dev/null @@ -1,332 +0,0 @@ -// Author: Yanko Shterev -// Thanks https://github.com/yshterev/videojs-settings-menu - -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import * as videojs from 'video.js' - -import { toTitleCase } from './utils' -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' - -const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') -const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') - -class SettingsMenuItem extends MenuItem { - - constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) { - super(player, options) - - this.settingsButton = menuButton - this.dialog = this.settingsButton.dialog - this.mainMenu = this.settingsButton.menu - this.panel = this.dialog.getChild('settingsPanel') - this.panelChild = this.panel.getChild('settingsPanelChild') - this.panelChildEl = this.panelChild.el_ - - this.size = null - - // keep state of what menu type is loading next - this.menuToLoad = 'mainmenu' - - const subMenuName = toTitleCase(entry) - const SubMenuComponent = videojsUntyped.getComponent(subMenuName) - - if (!SubMenuComponent) { - throw new Error(`Component ${subMenuName} does not exist`) - } - this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) - const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0] - this.settingsSubMenuEl_.className += ' ' + subMenuClass - - this.eventHandlers() - - player.ready(() => { - // Voodoo magic for IOS - setTimeout(() => { - this.build() - - // Update on rate change - player.on('ratechange', this.submenuClickHandler) - - if (subMenuName === 'CaptionsButton') { - // Hack to regenerate captions on HTTP fallback - player.on('captionsChanged', () => { - setTimeout(() => { - this.settingsSubMenuEl_.innerHTML = '' - this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) - this.update() - this.bindClickEvents() - - }, 0) - }) - } - - this.reset() - }, 0) - }) - } - - eventHandlers () { - this.submenuClickHandler = this.onSubmenuClick.bind(this) - this.transitionEndHandler = this.onTransitionEnd.bind(this) - } - - onSubmenuClick (event: any) { - let target = null - - if (event.type === 'tap') { - target = event.target - } else { - target = event.currentTarget - } - - if (target && target.classList.contains('vjs-back-button')) { - this.loadMainMenu() - return - } - - // To update the sub menu value on click, setTimeout is needed because - // updating the value is not instant - setTimeout(() => this.update(event), 0) - } - - /** - * Create the component's DOM element - * - * @return {Element} - * @method createEl - */ - createEl () { - const el = videojsUntyped.dom.createEl('li', { - className: 'vjs-menu-item' - }) - - this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', { - className: 'vjs-settings-sub-menu-title' - }) - - el.appendChild(this.settingsSubMenuTitleEl_) - - this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', { - className: 'vjs-settings-sub-menu-value' - }) - - el.appendChild(this.settingsSubMenuValueEl_) - - this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', { - className: 'vjs-settings-sub-menu' - }) - - return el - } - - /** - * Handle click on menu item - * - * @method handleClick - */ - handleClick () { - this.menuToLoad = 'submenu' - // Remove open class to ensure only the open submenu gets this class - videojsUntyped.dom.removeClass(this.el_, 'open') - - super.handleClick() - - this.mainMenu.el_.style.opacity = '0' - // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element - if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { - videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') - - // animation not played without timeout - setTimeout(() => { - this.settingsSubMenuEl_.style.opacity = '1' - this.settingsSubMenuEl_.style.marginRight = '0px' - }, 0) - - this.settingsButton.setDialogSize(this.size) - } else { - videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - } - } - - /** - * Create back button - * - * @method createBackButton - */ - createBackButton () { - const button = this.subMenu.menu.addChild('MenuItem', {}, 0) - button.name_ = 'BackButton' - button.addClass('vjs-back-button') - button.el_.innerHTML = this.player_.localize(this.subMenu.controlText_) - } - - /** - * Add/remove prefixed event listener for CSS Transition - * - * @method PrefixedEvent - */ - PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { - let prefix = ['webkit', 'moz', 'MS', 'o', ''] - - for (let p = 0; p < prefix.length; p++) { - if (!prefix[p]) { - type = type.toLowerCase() - } - - if (action === 'addEvent') { - element.addEventListener(prefix[p] + type, callback, false) - } else if (action === 'removeEvent') { - element.removeEventListener(prefix[p] + type, callback, false) - } - } - } - - onTransitionEnd (event: any) { - if (event.propertyName !== 'margin-right') { - return - } - - if (this.menuToLoad === 'mainmenu') { - // hide submenu - videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - - // reset opacity to 0 - this.settingsSubMenuEl_.style.opacity = '0' - } - } - - reset () { - videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - this.settingsSubMenuEl_.style.opacity = '0' - this.setMargin() - } - - loadMainMenu () { - this.menuToLoad = 'mainmenu' - this.mainMenu.show() - this.mainMenu.el_.style.opacity = '0' - - // back button will always take you to main menu, so set dialog sizes - this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height]) - - // animation not triggered without timeout (some async stuff ?!?) - setTimeout(() => { - // animate margin and opacity before hiding the submenu - // this triggers CSS Transition event - this.setMargin() - this.mainMenu.el_.style.opacity = '1' - }, 0) - } - - build () { - const saveUpdateLabel = this.subMenu.updateLabel - this.subMenu.updateLabel = () => { - this.update() - - saveUpdateLabel.call(this.subMenu) - } - - this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) - this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) - this.panelChildEl.appendChild(this.settingsSubMenuEl_) - this.update() - - this.createBackButton() - this.getSize() - this.bindClickEvents() - - // prefixed event listeners for CSS TransitionEnd - this.PrefixedEvent( - this.settingsSubMenuEl_, - 'TransitionEnd', - this.transitionEndHandler, - 'addEvent' - ) - } - - update (event?: any) { - let target: HTMLElement = null - let subMenu = this.subMenu.name() - - if (event && event.type === 'tap') { - target = event.target - } else if (event) { - target = event.currentTarget - } - - // Playback rate menu button doesn't get a vjs-selected class - // or sets options_['selected'] on the selected playback rate. - // Thus we get the submenu value based on the labelEl of playbackRateMenuButton - if (subMenu === 'PlaybackRateMenuButton') { - setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250) - } else { - // Loop trough the submenu items to find the selected child - for (let subMenuItem of this.subMenu.menu.children_) { - if (!(subMenuItem instanceof component)) { - continue - } - - if (subMenuItem.hasClass('vjs-selected')) { - // Prefer to use the function - if (typeof subMenuItem.getLabel === 'function') { - this.settingsSubMenuValueEl_.innerHTML = subMenuItem.getLabel() - break - } - - this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label - } - } - } - - if (target && !target.classList.contains('vjs-back-button')) { - this.settingsButton.hideDialog() - } - } - - bindClickEvents () { - for (let item of this.subMenu.menu.children()) { - if (!(item instanceof component)) { - continue - } - item.on(['tap', 'click'], this.submenuClickHandler) - } - } - - // save size of submenus on first init - // if number of submenu items change dynamically more logic will be needed - getSize () { - this.dialog.removeClass('vjs-hidden') - this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) - this.setMargin() - this.dialog.addClass('vjs-hidden') - videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - } - - setMargin () { - let [width] = this.size - - this.settingsSubMenuEl_.style.marginRight = `-${width}px` - } - - /** - * Hide the sub menu - */ - hideSubMenu () { - // after removing settings item this.el_ === null - if (!this.el_) { - return - } - - if (videojsUntyped.dom.hasClass(this.el_, 'open')) { - videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - videojsUntyped.dom.removeClass(this.el_, 'open') - } - } - -} - -SettingsMenuItem.prototype.contentElType = 'button' -videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem) - -export { SettingsMenuItem } diff --git a/client/src/assets/player/theater-button.ts b/client/src/assets/player/theater-button.ts deleted file mode 100644 index 4f8fede3d..000000000 --- a/client/src/assets/player/theater-button.ts +++ /dev/null @@ -1,50 +0,0 @@ -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import * as videojs from 'video.js' - -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -import { saveTheaterInStore, getStoredTheater } from './peertube-player-local-storage' - -const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') -class TheaterButton extends Button { - - private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' - - constructor (player: videojs.Player, options: any) { - super(player, options) - - const enabled = getStoredTheater() - if (enabled === true) { - this.player_.addClass(TheaterButton.THEATER_MODE_CLASS) - this.handleTheaterChange() - } - } - - buildCSSClass () { - return `vjs-theater-control ${super.buildCSSClass()}` - } - - handleTheaterChange () { - if (this.isTheaterEnabled()) { - this.controlText('Normal mode') - } else { - this.controlText('Theater mode') - } - - saveTheaterInStore(this.isTheaterEnabled()) - } - - handleClick () { - this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS) - - this.handleTheaterChange() - } - - private isTheaterEnabled () { - return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) - } -} - -TheaterButton.prototype.controlText_ = 'Theater mode' - -TheaterButton.registerComponent('TheaterButton', TheaterButton) diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/video-renderer.ts deleted file mode 100644 index a3415937b..000000000 --- a/client/src/assets/player/video-renderer.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Thanks: https://github.com/feross/render-media -// TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed - -const MediaElementWrapper = require('mediasource') -import { extname } from 'path' -const videostream = require('videostream') - -const VIDEOSTREAM_EXTS = [ - '.m4a', - '.m4v', - '.mp4' -] - -type RenderMediaOptions = { - controls: boolean - autoplay: boolean -} - -function renderVideo ( - file: any, - elem: HTMLVideoElement, - opts: RenderMediaOptions, - callback: (err: Error, renderer: any) => void -) { - validateFile(file) - - return renderMedia(file, elem, opts, callback) -} - -function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { - const extension = extname(file.name).toLowerCase() - let preparedElem: any = undefined - let currentTime = 0 - let renderer: any - - try { - if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) { - renderer = useVideostream() - } else { - renderer = useMediaSource() - } - } catch (err) { - return callback(err) - } - - function useVideostream () { - prepareElem() - preparedElem.addEventListener('error', function onError (err: Error) { - preparedElem.removeEventListener('error', onError) - - return callback(err) - }) - preparedElem.addEventListener('loadstart', onLoadStart) - return videostream(file, preparedElem) - } - - function useMediaSource (useVP9 = false) { - const codecs = getCodec(file.name, useVP9) - - prepareElem() - preparedElem.addEventListener('error', function onError (err: Error) { - preparedElem.removeEventListener('error', onError) - - // Try with vp9 before returning an error - if (codecs.indexOf('vp8') !== -1) return fallbackToMediaSource(true) - - return callback(err) - }) - preparedElem.addEventListener('loadstart', onLoadStart) - - const wrapper = new MediaElementWrapper(preparedElem) - const writable = wrapper.createWriteStream(codecs) - file.createReadStream().pipe(writable) - - if (currentTime) preparedElem.currentTime = currentTime - - return wrapper - } - - function fallbackToMediaSource (useVP9 = false) { - if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.') - else console.log('Falling back to media source..') - - useMediaSource(useVP9) - } - - function prepareElem () { - if (preparedElem === undefined) { - preparedElem = elem - - preparedElem.addEventListener('progress', function () { - currentTime = elem.currentTime - }) - } - } - - function onLoadStart () { - preparedElem.removeEventListener('loadstart', onLoadStart) - if (opts.autoplay) preparedElem.play() - - callback(null, renderer) - } -} - -function validateFile (file: any) { - if (file == null) { - throw new Error('file cannot be null or undefined') - } - if (typeof file.name !== 'string') { - throw new Error('missing or invalid file.name property') - } - if (typeof file.createReadStream !== 'function') { - throw new Error('missing or invalid file.createReadStream property') - } -} - -function getCodec (name: string, useVP9 = false) { - const ext = extname(name).toLowerCase() - if (ext === '.mp4') { - return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' - } - - if (ext === '.webm') { - if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' - - return 'video/webm; codecs="vp8, vorbis"' - } - - return undefined -} - -export { - renderVideo -} diff --git a/client/src/assets/player/videojs-components/p2p-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts new file mode 100644 index 000000000..03a5d29f0 --- /dev/null +++ b/client/src/assets/player/videojs-components/p2p-info-button.ts @@ -0,0 +1,102 @@ +import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +import { bytes } from '../utils' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +class P2pInfoButton extends Button { + + createEl () { + const div = videojsUntyped.dom.createEl('div', { + className: 'vjs-peertube' + }) + const subDivWebtorrent = videojsUntyped.dom.createEl('div', { + className: 'vjs-peertube-hidden' // Hide the stats before we get the info + }) + div.appendChild(subDivWebtorrent) + + const downloadIcon = videojsUntyped.dom.createEl('span', { + className: 'icon icon-download' + }) + subDivWebtorrent.appendChild(downloadIcon) + + const downloadSpeedText = videojsUntyped.dom.createEl('span', { + className: 'download-speed-text' + }) + const downloadSpeedNumber = videojsUntyped.dom.createEl('span', { + className: 'download-speed-number' + }) + const downloadSpeedUnit = videojsUntyped.dom.createEl('span') + downloadSpeedText.appendChild(downloadSpeedNumber) + downloadSpeedText.appendChild(downloadSpeedUnit) + subDivWebtorrent.appendChild(downloadSpeedText) + + const uploadIcon = videojsUntyped.dom.createEl('span', { + className: 'icon icon-upload' + }) + subDivWebtorrent.appendChild(uploadIcon) + + const uploadSpeedText = videojsUntyped.dom.createEl('span', { + className: 'upload-speed-text' + }) + const uploadSpeedNumber = videojsUntyped.dom.createEl('span', { + className: 'upload-speed-number' + }) + const uploadSpeedUnit = videojsUntyped.dom.createEl('span') + uploadSpeedText.appendChild(uploadSpeedNumber) + uploadSpeedText.appendChild(uploadSpeedUnit) + subDivWebtorrent.appendChild(uploadSpeedText) + + const peersText = videojsUntyped.dom.createEl('span', { + className: 'peers-text' + }) + const peersNumber = videojsUntyped.dom.createEl('span', { + className: 'peers-number' + }) + subDivWebtorrent.appendChild(peersNumber) + subDivWebtorrent.appendChild(peersText) + + const subDivHttp = videojsUntyped.dom.createEl('div', { + className: 'vjs-peertube-hidden' + }) + const subDivHttpText = videojsUntyped.dom.createEl('span', { + className: 'http-fallback', + textContent: 'HTTP' + }) + + subDivHttp.appendChild(subDivHttpText) + div.appendChild(subDivHttp) + + this.player_.on('p2pInfo', (event: any, data: any) => { + // We are in HTTP fallback + if (!data) { + subDivHttp.className = 'vjs-peertube-displayed' + subDivWebtorrent.className = 'vjs-peertube-hidden' + + return + } + + const downloadSpeed = bytes(data.downloadSpeed) + const uploadSpeed = bytes(data.uploadSpeed) + const totalDownloaded = bytes(data.downloaded) + const totalUploaded = bytes(data.uploaded) + const numPeers = data.numPeers + + subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + + this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) + + downloadSpeedNumber.textContent = downloadSpeed[ 0 ] + downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] + + uploadSpeedNumber.textContent = uploadSpeed[ 0 ] + uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] + + peersNumber.textContent = numPeers + peersText.textContent = ' ' + this.player_.localize('peers') + + subDivHttp.className = 'vjs-peertube-hidden' + subDivWebtorrent.className = 'vjs-peertube-displayed' + }) + + return div + } +} +Button.registerComponent('P2PInfoButton', P2pInfoButton) diff --git a/client/src/assets/player/videojs-components/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts new file mode 100644 index 000000000..fed8ea33e --- /dev/null +++ b/client/src/assets/player/videojs-components/peertube-link-button.ts @@ -0,0 +1,40 @@ +import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +import { buildVideoLink } from '../utils' +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +class PeerTubeLinkButton extends Button { + + constructor (player: Player, options: any) { + super(player, options) + } + + createEl () { + return this.buildElement() + } + + updateHref () { + this.el().setAttribute('href', buildVideoLink(this.player().currentTime())) + } + + handleClick () { + this.player_.pause() + } + + private buildElement () { + const el = videojsUntyped.dom.createEl('a', { + href: buildVideoLink(), + innerHTML: 'PeerTube', + title: this.player_.localize('Go to the video page'), + className: 'vjs-peertube-link', + target: '_blank' + }) + + el.addEventListener('mouseenter', () => this.updateHref()) + + return el + } +} +Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) diff --git a/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts new file mode 100644 index 000000000..9a0e3b550 --- /dev/null +++ b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts @@ -0,0 +1,38 @@ +import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + +const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') + +class PeerTubeLoadProgressBar extends Component { + + constructor (player: Player, options: any) { + super(player, options) + this.partEls_ = [] + this.on(player, 'progress', this.update) + } + + createEl () { + return super.createEl('div', { + className: 'vjs-load-progress', + innerHTML: `${this.localize('Loaded')}: 0%` + }) + } + + dispose () { + this.partEls_ = null + + super.dispose() + } + + update () { + const torrent = this.player().webtorrent().getTorrent() + if (!torrent) return + + this.el_.style.width = (torrent.progress * 100) + '%' + } + +} + +Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts new file mode 100644 index 000000000..2847de470 --- /dev/null +++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts @@ -0,0 +1,84 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + +import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +import { ResolutionMenuItem } from './resolution-menu-item' + +const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') +const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') +class ResolutionMenuButton extends MenuButton { + label: HTMLElement + + constructor (player: Player, options: any) { + super(player, options) + this.player = player + + player.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data)) + + if (player.webtorrent) { + player.webtorrent().on('videoFileUpdate', () => setTimeout(() => this.trigger('updateLabel'), 0)) + } + } + + createEl () { + const el = super.createEl() + + this.labelEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-resolution-value' + }) + + el.appendChild(this.labelEl_) + + return el + } + + updateARIAAttributes () { + this.el().setAttribute('aria-label', 'Quality') + } + + createMenu () { + return new Menu(this.player_) + } + + buildCSSClass () { + return super.buildCSSClass() + ' vjs-resolution-button' + } + + buildWrapperCSSClass () { + return 'vjs-resolution-control ' + super.buildWrapperCSSClass() + } + + private buildQualities (data: LoadedQualityData) { + // The automatic resolution item will need other labels + const labels: { [ id: number ]: string } = {} + + for (const d of data.qualityData.video) { + this.menu.addChild(new ResolutionMenuItem( + this.player_, + { + id: d.id, + label: d.label, + selected: d.selected, + callback: data.qualitySwitchCallback + }) + ) + + labels[d.id] = d.label + } + + this.menu.addChild(new ResolutionMenuItem( + this.player_, + { + id: -1, + label: this.player_.localize('Auto'), + labels, + callback: data.qualitySwitchCallback, + selected: true // By default, in auto mode + } + )) + } +} +ResolutionMenuButton.prototype.controlText_ = 'Quality' + +MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts new file mode 100644 index 000000000..cc1c79739 --- /dev/null +++ b/client/src/assets/player/videojs-components/resolution-menu-item.ts @@ -0,0 +1,87 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import { Player } from 'video.js' + +import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' + +const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') +class ResolutionMenuItem extends MenuItem { + private readonly id: number + private readonly label: string + // Only used for the automatic item + private readonly labels: { [id: number]: string } + private readonly callback: Function + + private autoResolutionPossible: boolean + private currentResolutionLabel: string + + constructor (player: Player, options: any) { + options.selectable = true + + super(player, options) + + this.autoResolutionPossible = true + this.currentResolutionLabel = '' + + this.label = options.label + this.labels = options.labels + this.id = options.id + this.callback = options.callback + + if (player.webtorrent) { + player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data)) + + // We only want to disable the "Auto" item + if (this.id === -1) { + player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data)) + } + } + + // TODO: update on HLS change + } + + handleClick (event: any) { + // Auto button disabled? + if (this.autoResolutionPossible === false && this.id === -1) return + + super.handleClick(event) + + this.callback(this.id) + } + + updateSelection (data: ResolutionUpdateData) { + if (this.id === -1) { + this.currentResolutionLabel = this.labels[data.resolutionId] + } + + // Automatic resolution only + if (data.auto === true) { + this.selected(this.id === -1) + return + } + + this.selected(this.id === data.resolutionId) + } + + updateAutoResolution (data: AutoResolutionUpdateData) { + // Check if the auto resolution is enabled or not + if (data.possible === false) { + this.addClass('disabled') + } else { + this.removeClass('disabled') + } + + this.autoResolutionPossible = data.possible + } + + getLabel () { + if (this.id === -1) { + return this.label + ' ' + this.currentResolutionLabel + '' + } + + return this.label + } +} +MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) + +export { ResolutionMenuItem } diff --git a/client/src/assets/player/videojs-components/settings-menu-button.ts b/client/src/assets/player/videojs-components/settings-menu-button.ts new file mode 100644 index 000000000..14cb8ba43 --- /dev/null +++ b/client/src/assets/player/videojs-components/settings-menu-button.ts @@ -0,0 +1,288 @@ +// Author: Yanko Shterev +// Thanks https://github.com/yshterev/videojs-settings-menu + +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + +import { SettingsMenuItem } from './settings-menu-item' +import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +import { toTitleCase } from '../utils' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') +const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') + +class SettingsButton extends Button { + constructor (player: videojs.Player, options: any) { + super(player, options) + + this.playerComponent = player + this.dialog = this.playerComponent.addChild('settingsDialog') + this.dialogEl = this.dialog.el_ + this.menu = null + this.panel = this.dialog.addChild('settingsPanel') + this.panelChild = this.panel.addChild('settingsPanelChild') + + this.addClass('vjs-settings') + this.el_.setAttribute('aria-label', 'Settings Button') + + // Event handlers + this.addSettingsItemHandler = this.onAddSettingsItem.bind(this) + this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this) + this.playerClickHandler = this.onPlayerClick.bind(this) + this.userInactiveHandler = this.onUserInactive.bind(this) + + this.buildMenu() + this.bindEvents() + + // Prepare the dialog + this.player().one('play', () => this.hideDialog()) + } + + onPlayerClick (event: MouseEvent) { + const element = event.target as HTMLElement + if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) { + return + } + + if (!this.dialog.hasClass('vjs-hidden')) { + this.hideDialog() + } + } + + onDisposeSettingsItem (event: any, name: string) { + if (name === undefined) { + let children = this.menu.children() + + while (children.length > 0) { + children[0].dispose() + this.menu.removeChild(children[0]) + } + + this.addClass('vjs-hidden') + } else { + let item = this.menu.getChild(name) + + if (item) { + item.dispose() + this.menu.removeChild(item) + } + } + + this.hideDialog() + + if (this.options_.entries.length === 0) { + this.addClass('vjs-hidden') + } + } + + onAddSettingsItem (event: any, data: any) { + const [ entry, options ] = data + + this.addMenuItem(entry, options) + this.removeClass('vjs-hidden') + } + + onUserInactive () { + if (!this.dialog.hasClass('vjs-hidden')) { + this.hideDialog() + } + } + + bindEvents () { + this.playerComponent.on('click', this.playerClickHandler) + this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler) + this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler) + this.playerComponent.on('userinactive', this.userInactiveHandler) + } + + buildCSSClass () { + return `vjs-icon-settings ${super.buildCSSClass()}` + } + + handleClick () { + if (this.dialog.hasClass('vjs-hidden')) { + this.showDialog() + } else { + this.hideDialog() + } + } + + showDialog () { + this.menu.el_.style.opacity = '1' + this.dialog.show() + + this.setDialogSize(this.getComponentSize(this.menu)) + } + + hideDialog () { + this.dialog.hide() + this.setDialogSize(this.getComponentSize(this.menu)) + this.menu.el_.style.opacity = '1' + this.resetChildren() + } + + getComponentSize (element: any) { + let width: number = null + let height: number = null + + // Could be component or just DOM element + if (element instanceof Component) { + width = element.el_.offsetWidth + height = element.el_.offsetHeight + + // keep width/height as properties for direct use + element.width = width + element.height = height + } else { + width = element.offsetWidth + height = element.offsetHeight + } + + return [ width, height ] + } + + setDialogSize ([ width, height ]: number[]) { + if (typeof height !== 'number') { + return + } + + let offset = this.options_.setup.maxHeightOffset + let maxHeight = this.playerComponent.el_.offsetHeight - offset + + if (height > maxHeight) { + height = maxHeight + width += 17 + this.panel.el_.style.maxHeight = `${height}px` + } else if (this.panel.el_.style.maxHeight !== '') { + this.panel.el_.style.maxHeight = '' + } + + this.dialogEl.style.width = `${width}px` + this.dialogEl.style.height = `${height}px` + } + + buildMenu () { + this.menu = new Menu(this.player()) + this.menu.addClass('vjs-main-menu') + let entries = this.options_.entries + + if (entries.length === 0) { + this.addClass('vjs-hidden') + this.panelChild.addChild(this.menu) + return + } + + for (let entry of entries) { + this.addMenuItem(entry, this.options_) + } + + this.panelChild.addChild(this.menu) + } + + addMenuItem (entry: any, options: any) { + const openSubMenu = function (this: any) { + if (videojsUntyped.dom.hasClass(this.el_, 'open')) { + videojsUntyped.dom.removeClass(this.el_, 'open') + } else { + videojsUntyped.dom.addClass(this.el_, 'open') + } + } + + options.name = toTitleCase(entry) + let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any) + + this.menu.addChild(settingsMenuItem) + + // Hide children to avoid sub menus stacking on top of each other + // or having multiple menus open + settingsMenuItem.on('click', videojs.bind(this, this.hideChildren)) + + // Whether to add or remove selected class on the settings sub menu element + settingsMenuItem.on('click', openSubMenu) + } + + resetChildren () { + for (let menuChild of this.menu.children()) { + menuChild.reset() + } + } + + /** + * Hide all the sub menus + */ + hideChildren () { + for (let menuChild of this.menu.children()) { + menuChild.hideSubMenu() + } + } + +} + +class SettingsPanel extends Component { + constructor (player: videojs.Player, options: any) { + super(player, options) + } + + createEl () { + return super.createEl('div', { + className: 'vjs-settings-panel', + innerHTML: '', + tabIndex: -1 + }) + } +} + +class SettingsPanelChild extends Component { + constructor (player: videojs.Player, options: any) { + super(player, options) + } + + createEl () { + return super.createEl('div', { + className: 'vjs-settings-panel-child', + innerHTML: '', + tabIndex: -1 + }) + } +} + +class SettingsDialog extends Component { + constructor (player: videojs.Player, options: any) { + super(player, options) + this.hide() + } + + /** + * Create the component's DOM element + * + * @return {Element} + * @method createEl + */ + createEl () { + const uniqueId = this.id_ + const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId + const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId + + return super.createEl('div', { + className: 'vjs-settings-dialog vjs-modal-overlay', + innerHTML: '', + tabIndex: -1 + }, { + 'role': 'dialog', + 'aria-labelledby': dialogLabelId, + 'aria-describedby': dialogDescriptionId + }) + } + +} + +SettingsButton.prototype.controlText_ = 'Settings' + +Component.registerComponent('SettingsButton', SettingsButton) +Component.registerComponent('SettingsDialog', SettingsDialog) +Component.registerComponent('SettingsPanel', SettingsPanel) +Component.registerComponent('SettingsPanelChild', SettingsPanelChild) + +export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild } diff --git a/client/src/assets/player/videojs-components/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts new file mode 100644 index 000000000..b9a430290 --- /dev/null +++ b/client/src/assets/player/videojs-components/settings-menu-item.ts @@ -0,0 +1,329 @@ +// Author: Yanko Shterev +// Thanks https://github.com/yshterev/videojs-settings-menu + +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + +import { toTitleCase } from '../utils' +import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' + +const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') +const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') + +class SettingsMenuItem extends MenuItem { + + constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) { + super(player, options) + + this.settingsButton = menuButton + this.dialog = this.settingsButton.dialog + this.mainMenu = this.settingsButton.menu + this.panel = this.dialog.getChild('settingsPanel') + this.panelChild = this.panel.getChild('settingsPanelChild') + this.panelChildEl = this.panelChild.el_ + + this.size = null + + // keep state of what menu type is loading next + this.menuToLoad = 'mainmenu' + + const subMenuName = toTitleCase(entry) + const SubMenuComponent = videojsUntyped.getComponent(subMenuName) + + if (!SubMenuComponent) { + throw new Error(`Component ${subMenuName} does not exist`) + } + this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) + const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0] + this.settingsSubMenuEl_.className += ' ' + subMenuClass + + this.eventHandlers() + + player.ready(() => { + // Voodoo magic for IOS + setTimeout(() => { + this.build() + + // Update on rate change + player.on('ratechange', this.submenuClickHandler) + + if (subMenuName === 'CaptionsButton') { + // Hack to regenerate captions on HTTP fallback + player.on('captionsChanged', () => { + setTimeout(() => { + this.settingsSubMenuEl_.innerHTML = '' + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) + this.update() + this.bindClickEvents() + + }, 0) + }) + } + + this.reset() + }, 0) + }) + } + + eventHandlers () { + this.submenuClickHandler = this.onSubmenuClick.bind(this) + this.transitionEndHandler = this.onTransitionEnd.bind(this) + } + + onSubmenuClick (event: any) { + let target = null + + if (event.type === 'tap') { + target = event.target + } else { + target = event.currentTarget + } + + if (target && target.classList.contains('vjs-back-button')) { + this.loadMainMenu() + return + } + + // To update the sub menu value on click, setTimeout is needed because + // updating the value is not instant + setTimeout(() => this.update(event), 0) + } + + /** + * Create the component's DOM element + * + * @return {Element} + * @method createEl + */ + createEl () { + const el = videojsUntyped.dom.createEl('li', { + className: 'vjs-menu-item' + }) + + this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-settings-sub-menu-title' + }) + + el.appendChild(this.settingsSubMenuTitleEl_) + + this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-settings-sub-menu-value' + }) + + el.appendChild(this.settingsSubMenuValueEl_) + + this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-settings-sub-menu' + }) + + return el + } + + /** + * Handle click on menu item + * + * @method handleClick + */ + handleClick () { + this.menuToLoad = 'submenu' + // Remove open class to ensure only the open submenu gets this class + videojsUntyped.dom.removeClass(this.el_, 'open') + + super.handleClick() + + this.mainMenu.el_.style.opacity = '0' + // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element + if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { + videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') + + // animation not played without timeout + setTimeout(() => { + this.settingsSubMenuEl_.style.opacity = '1' + this.settingsSubMenuEl_.style.marginRight = '0px' + }, 0) + + this.settingsButton.setDialogSize(this.size) + } else { + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + } + } + + /** + * Create back button + * + * @method createBackButton + */ + createBackButton () { + const button = this.subMenu.menu.addChild('MenuItem', {}, 0) + button.name_ = 'BackButton' + button.addClass('vjs-back-button') + button.el_.innerHTML = this.player_.localize(this.subMenu.controlText_) + } + + /** + * Add/remove prefixed event listener for CSS Transition + * + * @method PrefixedEvent + */ + PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { + let prefix = ['webkit', 'moz', 'MS', 'o', ''] + + for (let p = 0; p < prefix.length; p++) { + if (!prefix[p]) { + type = type.toLowerCase() + } + + if (action === 'addEvent') { + element.addEventListener(prefix[p] + type, callback, false) + } else if (action === 'removeEvent') { + element.removeEventListener(prefix[p] + type, callback, false) + } + } + } + + onTransitionEnd (event: any) { + if (event.propertyName !== 'margin-right') { + return + } + + if (this.menuToLoad === 'mainmenu') { + // hide submenu + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + + // reset opacity to 0 + this.settingsSubMenuEl_.style.opacity = '0' + } + } + + reset () { + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + this.settingsSubMenuEl_.style.opacity = '0' + this.setMargin() + } + + loadMainMenu () { + this.menuToLoad = 'mainmenu' + this.mainMenu.show() + this.mainMenu.el_.style.opacity = '0' + + // back button will always take you to main menu, so set dialog sizes + this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height]) + + // animation not triggered without timeout (some async stuff ?!?) + setTimeout(() => { + // animate margin and opacity before hiding the submenu + // this triggers CSS Transition event + this.setMargin() + this.mainMenu.el_.style.opacity = '1' + }, 0) + } + + build () { + this.subMenu.on('updateLabel', () => { + this.update() + }) + + this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) + this.panelChildEl.appendChild(this.settingsSubMenuEl_) + this.update() + + this.createBackButton() + this.getSize() + this.bindClickEvents() + + // prefixed event listeners for CSS TransitionEnd + this.PrefixedEvent( + this.settingsSubMenuEl_, + 'TransitionEnd', + this.transitionEndHandler, + 'addEvent' + ) + } + + update (event?: any) { + let target: HTMLElement = null + let subMenu = this.subMenu.name() + + if (event && event.type === 'tap') { + target = event.target + } else if (event) { + target = event.currentTarget + } + + // Playback rate menu button doesn't get a vjs-selected class + // or sets options_['selected'] on the selected playback rate. + // Thus we get the submenu value based on the labelEl of playbackRateMenuButton + if (subMenu === 'PlaybackRateMenuButton') { + setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250) + } else { + // Loop trough the submenu items to find the selected child + for (let subMenuItem of this.subMenu.menu.children_) { + if (!(subMenuItem instanceof component)) { + continue + } + + if (subMenuItem.hasClass('vjs-selected')) { + // Prefer to use the function + if (typeof subMenuItem.getLabel === 'function') { + this.settingsSubMenuValueEl_.innerHTML = subMenuItem.getLabel() + break + } + + this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label + } + } + } + + if (target && !target.classList.contains('vjs-back-button')) { + this.settingsButton.hideDialog() + } + } + + bindClickEvents () { + for (let item of this.subMenu.menu.children()) { + if (!(item instanceof component)) { + continue + } + item.on(['tap', 'click'], this.submenuClickHandler) + } + } + + // save size of submenus on first init + // if number of submenu items change dynamically more logic will be needed + getSize () { + this.dialog.removeClass('vjs-hidden') + this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) + this.setMargin() + this.dialog.addClass('vjs-hidden') + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + } + + setMargin () { + let [width] = this.size + + this.settingsSubMenuEl_.style.marginRight = `-${width}px` + } + + /** + * Hide the sub menu + */ + hideSubMenu () { + // after removing settings item this.el_ === null + if (!this.el_) { + return + } + + if (videojsUntyped.dom.hasClass(this.el_, 'open')) { + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + videojsUntyped.dom.removeClass(this.el_, 'open') + } + } + +} + +SettingsMenuItem.prototype.contentElType = 'button' +videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem) + +export { SettingsMenuItem } diff --git a/client/src/assets/player/videojs-components/theater-button.ts b/client/src/assets/player/videojs-components/theater-button.ts new file mode 100644 index 000000000..1e11a9546 --- /dev/null +++ b/client/src/assets/player/videojs-components/theater-button.ts @@ -0,0 +1,50 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + +import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +class TheaterButton extends Button { + + private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' + + constructor (player: videojs.Player, options: any) { + super(player, options) + + const enabled = getStoredTheater() + if (enabled === true) { + this.player_.addClass(TheaterButton.THEATER_MODE_CLASS) + this.handleTheaterChange() + } + } + + buildCSSClass () { + return `vjs-theater-control ${super.buildCSSClass()}` + } + + handleTheaterChange () { + if (this.isTheaterEnabled()) { + this.controlText('Normal mode') + } else { + this.controlText('Theater mode') + } + + saveTheaterInStore(this.isTheaterEnabled()) + } + + handleClick () { + this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS) + + this.handleTheaterChange() + } + + private isTheaterEnabled () { + return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) + } +} + +TheaterButton.prototype.controlText_ = 'Theater mode' + +TheaterButton.registerComponent('TheaterButton', TheaterButton) diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts deleted file mode 100644 index c3c1af951..000000000 --- a/client/src/assets/player/webtorrent-info-button.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' -import { bytes } from './utils' - -const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') -class WebtorrentInfoButton extends Button { - - createEl () { - const div = videojsUntyped.dom.createEl('div', { - className: 'vjs-peertube' - }) - const subDivWebtorrent = videojsUntyped.dom.createEl('div', { - className: 'vjs-peertube-hidden' // Hide the stats before we get the info - }) - div.appendChild(subDivWebtorrent) - - const downloadIcon = videojsUntyped.dom.createEl('span', { - className: 'icon icon-download' - }) - subDivWebtorrent.appendChild(downloadIcon) - - const downloadSpeedText = videojsUntyped.dom.createEl('span', { - className: 'download-speed-text' - }) - const downloadSpeedNumber = videojsUntyped.dom.createEl('span', { - className: 'download-speed-number' - }) - const downloadSpeedUnit = videojsUntyped.dom.createEl('span') - downloadSpeedText.appendChild(downloadSpeedNumber) - downloadSpeedText.appendChild(downloadSpeedUnit) - subDivWebtorrent.appendChild(downloadSpeedText) - - const uploadIcon = videojsUntyped.dom.createEl('span', { - className: 'icon icon-upload' - }) - subDivWebtorrent.appendChild(uploadIcon) - - const uploadSpeedText = videojsUntyped.dom.createEl('span', { - className: 'upload-speed-text' - }) - const uploadSpeedNumber = videojsUntyped.dom.createEl('span', { - className: 'upload-speed-number' - }) - const uploadSpeedUnit = videojsUntyped.dom.createEl('span') - uploadSpeedText.appendChild(uploadSpeedNumber) - uploadSpeedText.appendChild(uploadSpeedUnit) - subDivWebtorrent.appendChild(uploadSpeedText) - - const peersText = videojsUntyped.dom.createEl('span', { - className: 'peers-text' - }) - const peersNumber = videojsUntyped.dom.createEl('span', { - className: 'peers-number' - }) - subDivWebtorrent.appendChild(peersNumber) - subDivWebtorrent.appendChild(peersText) - - const subDivHttp = videojsUntyped.dom.createEl('div', { - className: 'vjs-peertube-hidden' - }) - const subDivHttpText = videojsUntyped.dom.createEl('span', { - className: 'http-fallback', - textContent: 'HTTP' - }) - - subDivHttp.appendChild(subDivHttpText) - div.appendChild(subDivHttp) - - this.player_.peertube().on('torrentInfo', (event: any, data: any) => { - // We are in HTTP fallback - if (!data) { - subDivHttp.className = 'vjs-peertube-displayed' - subDivWebtorrent.className = 'vjs-peertube-hidden' - - return - } - - const downloadSpeed = bytes(data.downloadSpeed) - const uploadSpeed = bytes(data.uploadSpeed) - const totalDownloaded = bytes(data.downloaded) - const totalUploaded = bytes(data.uploaded) - const numPeers = data.numPeers - - subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + - this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) - - downloadSpeedNumber.textContent = downloadSpeed[ 0 ] - downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] - - uploadSpeedNumber.textContent = uploadSpeed[ 0 ] - uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] - - peersNumber.textContent = numPeers - peersText.textContent = ' ' + this.player_.localize('peers') - - subDivHttp.className = 'vjs-peertube-hidden' - subDivWebtorrent.className = 'vjs-peertube-displayed' - }) - - return div - } -} -Button.registerComponent('WebTorrentButton', WebtorrentInfoButton) diff --git a/client/src/assets/player/webtorrent-plugin.ts b/client/src/assets/player/webtorrent-plugin.ts new file mode 100644 index 000000000..c3d990aed --- /dev/null +++ b/client/src/assets/player/webtorrent-plugin.ts @@ -0,0 +1,640 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + +import * as WebTorrent from 'webtorrent' +import { VideoFile } from '../../../../shared/models/videos/video.model' +import { renderVideo } from './webtorrent/video-renderer' +import { LoadedQualityData, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings' +import { videoFileMaxByResolution, videoFileMinByResolution } from './utils' +import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store' +import { + getAverageBandwidthInStore, + getStoredMute, + getStoredVolume, + getStoredWebTorrentEnabled, + saveAverageBandwidth +} from './peertube-player-local-storage' + +const CacheChunkStore = require('cache-chunk-store') + +type PlayOptions = { + forcePlay?: boolean, + seek?: number, + delay?: number +} + +const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') +class WebTorrentPlugin extends Plugin { + private readonly playerElement: HTMLVideoElement + + private readonly autoplay: boolean = false + private readonly startTime: number = 0 + private readonly savePlayerSrcFunction: Function + private readonly videoFiles: VideoFile[] + private readonly videoDuration: number + private readonly CONSTANTS = { + INFO_SCHEDULER: 1000, // Don't change this + AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds + AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it + AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check + AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds + BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth + } + + private readonly webtorrent = new WebTorrent({ + tracker: { + rtcConfig: { + iceServers: [ + { + urls: 'stun:stun.stunprotocol.org' + }, + { + urls: 'stun:stun.framasoft.org' + } + ] + } + }, + dht: false + }) + + private player: any + private currentVideoFile: VideoFile + private torrent: WebTorrent.Torrent + + private renderer: any + private fakeRenderer: any + private destroyingFakeRenderer = false + + private autoResolution = true + private autoResolutionPossible = true + private isAutoResolutionObservation = false + private playerRefusedP2P = false + + private torrentInfoInterval: any + private autoQualityInterval: any + private addTorrentDelay: any + private qualityObservationTimer: any + private runAutoQualitySchedulerTimer: any + + private downloadSpeeds: number[] = [] + + constructor (player: videojs.Player, options: WebtorrentPluginOptions) { + super(player, options) + + // Disable auto play on iOS + this.autoplay = options.autoplay && this.isIOS() === false + this.playerRefusedP2P = !getStoredWebTorrentEnabled() + + this.videoFiles = options.videoFiles + this.videoDuration = options.videoDuration + + this.savePlayerSrcFunction = this.player.src + this.playerElement = options.playerElement + + this.player.ready(() => { + const playerOptions = this.player.options_ + + const volume = getStoredVolume() + if (volume !== undefined) this.player.volume(volume) + + const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() + if (muted !== undefined) this.player.muted(muted) + + this.player.duration(options.videoDuration) + + this.initializePlayer() + this.runTorrentInfoScheduler() + + this.player.one('play', () => { + // Don't run immediately scheduler, wait some seconds the TCP connections are made + this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) + }) + }) + } + + dispose () { + clearTimeout(this.addTorrentDelay) + clearTimeout(this.qualityObservationTimer) + clearTimeout(this.runAutoQualitySchedulerTimer) + + clearInterval(this.torrentInfoInterval) + clearInterval(this.autoQualityInterval) + + // Don't need to destroy renderer, video player will be destroyed + this.flushVideoFile(this.currentVideoFile, false) + + this.destroyFakeRenderer() + } + + getCurrentResolutionId () { + return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 + } + + updateVideoFile ( + videoFile?: VideoFile, + options: { + forcePlay?: boolean, + seek?: number, + delay?: number + } = {}, + done: () => void = () => { /* empty */ } + ) { + // Automatically choose the adapted video file + if (videoFile === undefined) { + const savedAverageBandwidth = getAverageBandwidthInStore() + videoFile = savedAverageBandwidth + ? this.getAppropriateFile(savedAverageBandwidth) + : this.pickAverageVideoFile() + } + + // Don't add the same video file once again + if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { + return + } + + // Do not display error to user because we will have multiple fallback + this.disableErrorDisplay() + + // Hack to "simulate" src link in video.js >= 6 + // Without this, we can't play the video after pausing it + // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 + this.player.src = () => true + const oldPlaybackRate = this.player.playbackRate() + + const previousVideoFile = this.currentVideoFile + this.currentVideoFile = videoFile + + // Don't try on iOS that does not support MediaSource + // Or don't use P2P if webtorrent is disabled + if (this.isIOS() || this.playerRefusedP2P) { + return this.fallbackToHttp(options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + } + + this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + + this.changeQuality() + this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id }) + } + + updateResolution (resolutionId: number, delay = 0) { + // Remember player state + const currentTime = this.player.currentTime() + const isPaused = this.player.paused() + + // Remove poster to have black background + this.playerElement.poster = '' + + // Hide bigPlayButton + if (!isPaused) { + this.player.bigPlayButton.hide() + } + + const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) + const options = { + forcePlay: false, + delay, + seek: currentTime + (delay / 1000) + } + this.updateVideoFile(newVideoFile, options) + } + + flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { + if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { + if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() + + this.webtorrent.remove(videoFile.magnetUri) + console.log('Removed ' + videoFile.magnetUri) + } + } + + enableAutoResolution () { + this.autoResolution = true + this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) + } + + disableAutoResolution (forbid = false) { + if (forbid === true) this.autoResolutionPossible = false + + this.autoResolution = false + this.trigger('autoResolutionUpdate', { possible: this.autoResolutionPossible }) + this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) + } + + getTorrent () { + return this.torrent + } + + private addTorrent ( + magnetOrTorrentUrl: string, + previousVideoFile: VideoFile, + options: PlayOptions, + done: Function + ) { + console.log('Adding ' + magnetOrTorrentUrl + '.') + + const oldTorrent = this.torrent + const torrentOptions = { + store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { + max: 100 + }) + } + + this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { + console.log('Added ' + magnetOrTorrentUrl + '.') + + if (oldTorrent) { + // Pause the old torrent + this.stopTorrent(oldTorrent) + + // We use a fake renderer so we download correct pieces of the next file + if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay) + } + + // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) + this.addTorrentDelay = setTimeout(() => { + // We don't need the fake renderer anymore + this.destroyFakeRenderer() + + const paused = this.player.paused() + + this.flushVideoFile(previousVideoFile) + + // Update progress bar (just for the UI), do not wait rendering + if (options.seek) this.player.currentTime(options.seek) + + const renderVideoOptions = { autoplay: false, controls: true } + renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { + this.renderer = renderer + + if (err) return this.fallbackToHttp(options, done) + + return this.tryToPlay(err => { + if (err) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + return done() + }) + }) + }, options.delay || 0) + }) + + this.torrent.on('error', (err: any) => console.error(err)) + + this.torrent.on('warning', (err: any) => { + // We don't support HTTP tracker but we don't care -> we use the web socket tracker + if (err.message.indexOf('Unsupported tracker protocol') !== -1) return + + // Users don't care about issues with WebRTC, but developers do so log it in the console + if (err.message.indexOf('Ice connection failed') !== -1) { + console.log(err) + return + } + + // Magnet hash is not up to date with the torrent file, add directly the torrent file + if (err.message.indexOf('incorrect info hash') !== -1) { + console.error('Incorrect info hash detected, falling back to torrent file.') + const newOptions = { forcePlay: true, seek: options.seek } + return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done) + } + + // Remote instance is down + if (err.message.indexOf('from xs param') !== -1) { + this.handleError(err) + } + + console.warn(err) + }) + } + + private tryToPlay (done?: (err?: Error) => void) { + if (!done) done = function () { /* empty */ } + + const playPromise = this.player.play() + if (playPromise !== undefined) { + return playPromise.then(done) + .catch((err: Error) => { + if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) { + return + } + + console.error(err) + this.player.pause() + this.player.posterImage.show() + this.player.removeClass('vjs-has-autoplay') + this.player.removeClass('vjs-has-big-play-button-clicked') + + return done() + }) + } + + return done() + } + + private seek (time: number) { + this.player.currentTime(time) + this.player.handleTechSeeked_() + } + + private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { + if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined + if (this.videoFiles.length === 1) return this.videoFiles[0] + + // Don't change the torrent is the play was ended + if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile + + if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() + + // Limit resolution according to player height + const playerHeight = this.playerElement.offsetHeight as number + + // We take the first resolution just above the player height + // Example: player height is 530px, we want the 720p file instead of 480p + let maxResolution = this.videoFiles[0].resolution.id + for (let i = this.videoFiles.length - 1; i >= 0; i--) { + const resolutionId = this.videoFiles[i].resolution.id + if (resolutionId >= playerHeight) { + maxResolution = resolutionId + break + } + } + + // Filter videos we can play according to our screen resolution and bandwidth + const filteredFiles = this.videoFiles + .filter(f => f.resolution.id <= maxResolution) + .filter(f => { + const fileBitrate = (f.size / this.videoDuration) + let threshold = fileBitrate + + // If this is for a higher resolution or an initial load: add a margin + if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { + threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) + } + + return averageDownloadSpeed > threshold + }) + + // If the download speed is too bad, return the lowest resolution we have + if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles) + + return videoFileMaxByResolution(filteredFiles) + } + + private getAndSaveActualDownloadSpeed () { + const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) + const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) + if (lastDownloadSpeeds.length === 0) return -1 + + const sum = lastDownloadSpeeds.reduce((a, b) => a + b) + const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) + + // Save the average bandwidth for future use + saveAverageBandwidth(averageBandwidth) + + return averageBandwidth + } + + private initializePlayer () { + this.buildQualities() + + if (this.autoplay === true) { + this.player.posterImage.hide() + + return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) + } + + // Proxy first play + const oldPlay = this.player.play.bind(this.player) + this.player.play = () => { + this.player.addClass('vjs-has-big-play-button-clicked') + this.player.play = oldPlay + + this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) + } + } + + private runAutoQualityScheduler () { + this.autoQualityInterval = setInterval(() => { + + // Not initialized or in HTTP fallback + if (this.torrent === undefined || this.torrent === null) return + if (this.autoResolution === false) return + if (this.isAutoResolutionObservation === true) return + + const file = this.getAppropriateFile() + let changeResolution = false + let changeResolutionDelay = 0 + + // Lower resolution + if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { + console.log('Downgrading automatically the resolution to: %s', file.resolution.label) + changeResolution = true + } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution + console.log('Upgrading automatically the resolution to: %s', file.resolution.label) + changeResolution = true + changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY + } + + if (changeResolution === true) { + this.updateResolution(file.resolution.id, changeResolutionDelay) + + // Wait some seconds in observation of our new resolution + this.isAutoResolutionObservation = true + + this.qualityObservationTimer = setTimeout(() => { + this.isAutoResolutionObservation = false + }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) + } + }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) + } + + private isPlayerWaiting () { + return this.player && this.player.hasClass('vjs-waiting') + } + + private runTorrentInfoScheduler () { + this.torrentInfoInterval = setInterval(() => { + // Not initialized yet + if (this.torrent === undefined) return + + // Http fallback + if (this.torrent === null) return this.player.trigger('p2pInfo', false) + + // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too + if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) + + return this.player.trigger('p2pInfo', { + downloadSpeed: this.torrent.downloadSpeed, + numPeers: this.torrent.numPeers, + uploadSpeed: this.torrent.uploadSpeed, + downloaded: this.torrent.downloaded, + uploaded: this.torrent.uploaded + }) + }, this.CONSTANTS.INFO_SCHEDULER) + } + + private fallbackToHttp (options: PlayOptions, done?: Function) { + const paused = this.player.paused() + + this.disableAutoResolution(true) + + this.flushVideoFile(this.currentVideoFile, true) + this.torrent = null + + // Enable error display now this is our last fallback + this.player.one('error', () => this.enableErrorDisplay()) + + const httpUrl = this.currentVideoFile.fileUrl + this.player.src = this.savePlayerSrcFunction + this.player.src(httpUrl) + + this.changeQuality() + + // We changed the source, so reinit captions + this.player.trigger('sourcechange') + + return this.tryToPlay(err => { + if (err && done) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + if (done) return done() + }) + } + + private handleError (err: Error | string) { + return this.player.trigger('customError', { err }) + } + + private enableErrorDisplay () { + this.player.addClass('vjs-error-display-enabled') + } + + private disableErrorDisplay () { + this.player.removeClass('vjs-error-display-enabled') + } + + private isIOS () { + return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) + } + + private pickAverageVideoFile () { + if (this.videoFiles.length === 1) return this.videoFiles[0] + + return this.videoFiles[Math.floor(this.videoFiles.length / 2)] + } + + private stopTorrent (torrent: WebTorrent.Torrent) { + torrent.pause() + // Pause does not remove actual peers (in particular the webseed peer) + torrent.removePeer(torrent[ 'ws' ]) + } + + private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { + this.destroyingFakeRenderer = false + + const fakeVideoElem = document.createElement('video') + renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { + this.fakeRenderer = renderer + + // The renderer returns an error when we destroy it, so skip them + if (this.destroyingFakeRenderer === false && err) { + console.error('Cannot render new torrent in fake video element.', err) + } + + // Load the future file at the correct time (in delay MS - 2 seconds) + fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) + }) + } + + private destroyFakeRenderer () { + if (this.fakeRenderer) { + this.destroyingFakeRenderer = true + + if (this.fakeRenderer.destroy) { + try { + this.fakeRenderer.destroy() + } catch (err) { + console.log('Cannot destroy correctly fake renderer.', err) + } + } + this.fakeRenderer = undefined + } + } + + private buildQualities () { + const qualityLevelsPayload = [] + + for (const file of this.videoFiles) { + const representation = { + id: file.resolution.id, + label: this.buildQualityLabel(file), + height: file.resolution.id, + _enabled: true + } + + this.player.qualityLevels().addQualityLevel(representation) + + qualityLevelsPayload.push({ + id: representation.id, + label: representation.label, + selected: false + }) + } + + const payload: LoadedQualityData = { + qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d), + qualityData: { + video: qualityLevelsPayload + } + } + this.player.trigger('loadedqualitydata', payload) + } + + private buildQualityLabel (file: VideoFile) { + let label = file.resolution.label + + if (file.fps && file.fps >= 50) { + label += file.fps + } + + return label + } + + private qualitySwitchCallback (id: number) { + if (id === -1) { + if (this.autoResolutionPossible === true) this.enableAutoResolution() + return + } + + this.disableAutoResolution() + this.updateResolution(id) + } + + private changeQuality () { + const resolutionId = this.currentVideoFile.resolution.id + const qualityLevels = this.player.qualityLevels() + + if (resolutionId === -1) { + qualityLevels.selectedIndex = -1 + return + } + + for (let i = 0; i < qualityLevels; i++) { + const q = this.player.qualityLevels[i] + if (q.height === resolutionId) qualityLevels.selectedIndex = i + } + } +} + +videojs.registerPlugin('webtorrent', WebTorrentPlugin) +export { WebTorrentPlugin } diff --git a/client/src/assets/player/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/webtorrent/peertube-chunk-store.ts new file mode 100644 index 000000000..54cc0ea64 --- /dev/null +++ b/client/src/assets/player/webtorrent/peertube-chunk-store.ts @@ -0,0 +1,231 @@ +// From https://github.com/MinEduTDF/idb-chunk-store +// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues +// Thanks @santiagogil and @Feross + +import { EventEmitter } from 'events' +import Dexie from 'dexie' + +class ChunkDatabase extends Dexie { + chunks: Dexie.Table<{ id: number, buf: Buffer }, number> + + constructor (dbname: string) { + super(dbname) + + this.version(1).stores({ + chunks: 'id' + }) + } +} + +class ExpirationDatabase extends Dexie { + databases: Dexie.Table<{ name: string, expiration: number }, number> + + constructor () { + super('webtorrent-expiration') + + this.version(1).stores({ + databases: 'name,expiration' + }) + } +} + +export class PeertubeChunkStore extends EventEmitter { + private static readonly BUFFERING_PUT_MS = 1000 + private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute + private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes + + chunkLength: number + + private pendingPut: { id: number, buf: Buffer, cb: Function }[] = [] + // If the store is full + private memoryChunks: { [ id: number ]: Buffer | true } = {} + private databaseName: string + private putBulkTimeout: any + private cleanerInterval: any + private db: ChunkDatabase + private expirationDB: ExpirationDatabase + private readonly length: number + private readonly lastChunkLength: number + private readonly lastChunkIndex: number + + constructor (chunkLength: number, opts: any) { + super() + + this.databaseName = 'webtorrent-chunks-' + + if (!opts) opts = {} + if (opts.torrent && opts.torrent.infoHash) this.databaseName += opts.torrent.infoHash + else this.databaseName += '-default' + + this.setMaxListeners(100) + + this.chunkLength = Number(chunkLength) + if (!this.chunkLength) throw new Error('First argument must be a chunk length') + + this.length = Number(opts.length) || Infinity + + if (this.length !== Infinity) { + this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength + this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 + } + + this.db = new ChunkDatabase(this.databaseName) + // Track databases that expired + this.expirationDB = new ExpirationDatabase() + + this.runCleaner() + } + + put (index: number, buf: Buffer, cb: (err?: Error) => void) { + const isLastChunk = (index === this.lastChunkIndex) + if (isLastChunk && buf.length !== this.lastChunkLength) { + return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) + } + if (!isLastChunk && buf.length !== this.chunkLength) { + return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) + } + + // Specify we have this chunk + this.memoryChunks[index] = true + + // Add it to the pending put + this.pendingPut.push({ id: index, buf, cb }) + // If it's already planned, return + if (this.putBulkTimeout) return + + // Plan a future bulk insert + this.putBulkTimeout = setTimeout(async () => { + const processing = this.pendingPut + this.pendingPut = [] + this.putBulkTimeout = undefined + + try { + await this.db.transaction('rw', this.db.chunks, () => { + return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) + }) + } catch (err) { + console.log('Cannot bulk insert chunks. Store them in memory.', { err }) + + processing.forEach(p => this.memoryChunks[ p.id ] = p.buf) + } finally { + processing.forEach(p => p.cb()) + } + }, PeertubeChunkStore.BUFFERING_PUT_MS) + } + + get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { + if (typeof opts === 'function') return this.get(index, null, opts) + + // IndexDB could be slow, use our memory index first + const memoryChunk = this.memoryChunks[index] + if (memoryChunk === undefined) { + const err = new Error('Chunk not found') as any + err['notFound'] = true + + return process.nextTick(() => cb(err)) + } + + // Chunk in memory + if (memoryChunk !== true) return cb(null, memoryChunk) + + // Chunk in store + this.db.transaction('r', this.db.chunks, async () => { + const result = await this.db.chunks.get({ id: index }) + if (result === undefined) return cb(null, new Buffer(0)) + + const buf = result.buf + if (!opts) return this.nextTick(cb, null, buf) + + const offset = opts.offset || 0 + const len = opts.length || (buf.length - offset) + return cb(null, buf.slice(offset, len + offset)) + }) + .catch(err => { + console.error(err) + return cb(err) + }) + } + + close (cb: (err?: Error) => void) { + return this.destroy(cb) + } + + async destroy (cb: (err?: Error) => void) { + try { + if (this.pendingPut) { + clearTimeout(this.putBulkTimeout) + this.pendingPut = null + } + if (this.cleanerInterval) { + clearInterval(this.cleanerInterval) + this.cleanerInterval = null + } + + if (this.db) { + await this.db.close() + + await this.dropDatabase(this.databaseName) + } + + if (this.expirationDB) { + await this.expirationDB.close() + this.expirationDB = null + } + + return cb() + } catch (err) { + console.error('Cannot destroy peertube chunk store.', err) + return cb(err) + } + } + + private runCleaner () { + this.checkExpiration() + + this.cleanerInterval = setInterval(async () => { + this.checkExpiration() + }, PeertubeChunkStore.CLEANER_INTERVAL_MS) + } + + private async checkExpiration () { + let databasesToDeleteInfo: { name: string }[] = [] + + try { + await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { + // Update our database expiration since we are alive + await this.expirationDB.databases.put({ + name: this.databaseName, + expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS + }) + + const now = new Date().getTime() + databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() + }) + } catch (err) { + console.error('Cannot update expiration of fetch expired databases.', err) + } + + for (const databaseToDeleteInfo of databasesToDeleteInfo) { + await this.dropDatabase(databaseToDeleteInfo.name) + } + } + + private async dropDatabase (databaseName: string) { + const dbToDelete = new ChunkDatabase(databaseName) + console.log('Destroying IndexDB database %s.', databaseName) + + try { + await dbToDelete.delete() + + await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { + return this.expirationDB.databases.where({ name: databaseName }).delete() + }) + } catch (err) { + console.error('Cannot delete %s.', databaseName, err) + } + } + + private nextTick (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { + process.nextTick(() => cb(err, val), undefined) + } +} diff --git a/client/src/assets/player/webtorrent/video-renderer.ts b/client/src/assets/player/webtorrent/video-renderer.ts new file mode 100644 index 000000000..a3415937b --- /dev/null +++ b/client/src/assets/player/webtorrent/video-renderer.ts @@ -0,0 +1,134 @@ +// Thanks: https://github.com/feross/render-media +// TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed + +const MediaElementWrapper = require('mediasource') +import { extname } from 'path' +const videostream = require('videostream') + +const VIDEOSTREAM_EXTS = [ + '.m4a', + '.m4v', + '.mp4' +] + +type RenderMediaOptions = { + controls: boolean + autoplay: boolean +} + +function renderVideo ( + file: any, + elem: HTMLVideoElement, + opts: RenderMediaOptions, + callback: (err: Error, renderer: any) => void +) { + validateFile(file) + + return renderMedia(file, elem, opts, callback) +} + +function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { + const extension = extname(file.name).toLowerCase() + let preparedElem: any = undefined + let currentTime = 0 + let renderer: any + + try { + if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) { + renderer = useVideostream() + } else { + renderer = useMediaSource() + } + } catch (err) { + return callback(err) + } + + function useVideostream () { + prepareElem() + preparedElem.addEventListener('error', function onError (err: Error) { + preparedElem.removeEventListener('error', onError) + + return callback(err) + }) + preparedElem.addEventListener('loadstart', onLoadStart) + return videostream(file, preparedElem) + } + + function useMediaSource (useVP9 = false) { + const codecs = getCodec(file.name, useVP9) + + prepareElem() + preparedElem.addEventListener('error', function onError (err: Error) { + preparedElem.removeEventListener('error', onError) + + // Try with vp9 before returning an error + if (codecs.indexOf('vp8') !== -1) return fallbackToMediaSource(true) + + return callback(err) + }) + preparedElem.addEventListener('loadstart', onLoadStart) + + const wrapper = new MediaElementWrapper(preparedElem) + const writable = wrapper.createWriteStream(codecs) + file.createReadStream().pipe(writable) + + if (currentTime) preparedElem.currentTime = currentTime + + return wrapper + } + + function fallbackToMediaSource (useVP9 = false) { + if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.') + else console.log('Falling back to media source..') + + useMediaSource(useVP9) + } + + function prepareElem () { + if (preparedElem === undefined) { + preparedElem = elem + + preparedElem.addEventListener('progress', function () { + currentTime = elem.currentTime + }) + } + } + + function onLoadStart () { + preparedElem.removeEventListener('loadstart', onLoadStart) + if (opts.autoplay) preparedElem.play() + + callback(null, renderer) + } +} + +function validateFile (file: any) { + if (file == null) { + throw new Error('file cannot be null or undefined') + } + if (typeof file.name !== 'string') { + throw new Error('missing or invalid file.name property') + } + if (typeof file.createReadStream !== 'function') { + throw new Error('missing or invalid file.createReadStream property') + } +} + +function getCodec (name: string, useVP9 = false) { + const ext = extname(name).toLowerCase() + if (ext === '.mp4') { + return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' + } + + if (ext === '.webm') { + if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' + + return 'video/webm; codecs="vp8, vorbis"' + } + + return undefined +} + +export { + renderVideo +} -- cgit v1.2.3 From 3b6f205c34bb931de0323581edf991ca33256e6b Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 24 Jan 2019 10:16:30 +0100 Subject: Correctly implement p2p-media-loader --- .../src/assets/player/p2p-media-loader-plugin.ts | 82 ++++++++++++++++++++-- .../src/assets/player/peertube-player-manager.ts | 12 ++-- client/src/assets/player/peertube-plugin.ts | 41 ++++++++++- .../src/assets/player/peertube-videojs-typings.ts | 12 ++++ .../player/videojs-components/p2p-info-button.ts | 16 +++-- .../videojs-components/resolution-menu-button.ts | 33 +++++++-- .../videojs-components/resolution-menu-item.ts | 18 ++--- .../videojs-components/settings-menu-item.ts | 10 ++- client/src/assets/player/webtorrent-plugin.ts | 26 +++---- 9 files changed, 203 insertions(+), 47 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader-plugin.ts index 6d07a2c9c..25117e51e 100644 --- a/client/src/assets/player/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/p2p-media-loader-plugin.ts @@ -1,25 +1,45 @@ // FIXME: something weird with our path definition in tsconfig and typings // @ts-ignore import * as videojs from 'video.js' -import { P2PMediaLoaderPluginOptions, VideoJSComponentInterface } from './peertube-videojs-typings' +import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from './peertube-videojs-typings' // videojs-hlsjs-plugin needs videojs in window window['videojs'] = videojs import '@streamroot/videojs-hlsjs-plugin' -import { initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' - -// import { Events } from '../p2p-media-loader/p2p-media-loader-core/lib' +import { Engine, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' +import * as Hls from 'hls.js' +import { Events } from 'p2p-media-loader-core' const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') class P2pMediaLoaderPlugin extends Plugin { + private readonly CONSTANTS = { + INFO_SCHEDULER: 1000 // Don't change this + } + + private hlsjs: Hls + private p2pEngine: Engine + private statsP2PBytes = { + pendingDownload: [] as number[], + pendingUpload: [] as number[], + numPeers: 0, + totalDownload: 0, + totalUpload: 0 + } + + private networkInfoInterval: any + constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { super(player, options) - initVideoJsContribHlsJsPlayer(player) + videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: Hls) => { + this.hlsjs = hlsjs - console.log(options) + this.initialize() + }) + + initVideoJsContribHlsJsPlayer(player) player.src({ type: options.type, @@ -27,6 +47,56 @@ class P2pMediaLoaderPlugin extends Plugin { }) } + dispose () { + clearInterval(this.networkInfoInterval) + } + + private initialize () { + this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine() + + this.hlsjs.on(Hls.Events.LEVEL_SWITCHING, (_, data: Hls.levelSwitchingData) => { + this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height }) + }) + + this.runStats() + } + + private runStats () { + this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => { + if (method === 'p2p') { + this.statsP2PBytes.pendingDownload.push(size) + this.statsP2PBytes.totalDownload += size + } + }) + + this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => { + if (method === 'p2p') { + this.statsP2PBytes.pendingUpload.push(size) + this.statsP2PBytes.totalUpload += size + } + }) + + this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++) + this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--) + + this.networkInfoInterval = setInterval(() => { + let downloadSpeed = this.statsP2PBytes.pendingDownload.reduce((a: number, b: number) => a + b, 0) + let uploadSpeed = this.statsP2PBytes.pendingUpload.reduce((a: number, b: number) => a + b, 0) + + this.statsP2PBytes.pendingDownload = [] + this.statsP2PBytes.pendingUpload = [] + + return this.player.trigger('p2pInfo', { + p2p: { + downloadSpeed, + uploadSpeed, + numPeers: this.statsP2PBytes.numPeers, + downloaded: this.statsP2PBytes.totalDownload, + uploaded: this.statsP2PBytes.totalUpload + } + } as PlayerNetworkInfo) + }, this.CONSTANTS.INFO_SCHEDULER) + } } videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 9155c0698..2e090847c 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -24,17 +24,17 @@ videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitle // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' -type PlayerMode = 'webtorrent' | 'p2p-media-loader' +export type PlayerMode = 'webtorrent' | 'p2p-media-loader' -type WebtorrentOptions = { +export type WebtorrentOptions = { videoFiles: VideoFile[] } -type P2PMediaLoaderOptions = { +export type P2PMediaLoaderOptions = { playlistUrl: string } -type CommonOptions = { +export type CommonOptions = { playerElement: HTMLVideoElement autoplay: boolean @@ -137,6 +137,7 @@ export class PeertubePlayerManager { const commonOptions = options.common const webtorrentOptions = options.webtorrent const p2pMediaLoaderOptions = options.p2pMediaLoader + let html5 = {} const plugins: VideoJSPluginOptions = { peertube: { @@ -171,6 +172,7 @@ export class PeertubePlayerManager { } Object.assign(plugins, { p2pMediaLoader, streamrootHls }) + html5 = streamrootHls.html5 } if (webtorrentOptions) { @@ -184,6 +186,8 @@ export class PeertubePlayerManager { } const videojsOptions = { + html5, + // We don't use text track settings for now textTrackSettings: false, controls: commonOptions.controls !== undefined ? commonOptions.controls : true, diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index 0bd607697..f83d9094a 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts @@ -2,7 +2,14 @@ // @ts-ignore import * as videojs from 'video.js' import './videojs-components/settings-menu-button' -import { PeerTubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +import { + PeerTubePluginOptions, + ResolutionUpdateData, + UserWatching, + VideoJSCaption, + VideoJSComponentInterface, + videojsUntyped +} from './peertube-videojs-typings' import { isMobile, timeToInt } from './utils' import { getStoredLastSubtitle, @@ -30,6 +37,7 @@ class PeerTubePlugin extends Plugin { private videoViewInterval: any private userWatchingVideoInterval: any private qualityObservationTimer: any + private lastResolutionChange: ResolutionUpdateData constructor (player: videojs.Player, options: PeerTubePluginOptions) { super(player, options) @@ -44,6 +52,22 @@ class PeerTubePlugin extends Plugin { this.player.ready(() => { const playerOptions = this.player.options_ + if (this.player.webtorrent) { + this.player.webtorrent().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) + this.player.webtorrent().on('autoResolutionChange', (_: any, d: any) => this.trigger('autoResolutionChange', d)) + } + + if (this.player.p2pMediaLoader) { + this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) + } + + this.player.tech_.on('loadedqualitydata', () => { + setTimeout(() => { + // Replay a resolution change, now we loaded all quality data + if (this.lastResolutionChange) this.handleResolutionChange(this.lastResolutionChange) + }, 0) + }) + const volume = getStoredVolume() if (volume !== undefined) this.player.volume(volume) @@ -158,6 +182,21 @@ class PeerTubePlugin extends Plugin { return fetch(url, { method: 'PUT', body, headers }) } + private handleResolutionChange (data: ResolutionUpdateData) { + this.lastResolutionChange = data + + const qualityLevels = this.player.qualityLevels() + + for (let i = 0; i < qualityLevels.length; i++) { + if (qualityLevels[i].height === data.resolutionId) { + data.id = qualityLevels[i].id + break + } + } + + this.trigger('resolutionChange', data) + } + private alterInactivity () { let saveInactivityTimeout: number diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 060ea4dce..fff992a6f 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -83,13 +83,25 @@ type LoadedQualityData = { type ResolutionUpdateData = { auto: boolean, resolutionId: number + id?: number } type AutoResolutionUpdateData = { possible: boolean } +type PlayerNetworkInfo = { + p2p: { + downloadSpeed: number + uploadSpeed: number + downloaded: number + uploaded: number + numPeers: number + } +} + export { + PlayerNetworkInfo, ResolutionUpdateData, AutoResolutionUpdateData, VideoJSComponentInterface, diff --git a/client/src/assets/player/videojs-components/p2p-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts index 03a5d29f0..2fc4c4562 100644 --- a/client/src/assets/player/videojs-components/p2p-info-button.ts +++ b/client/src/assets/player/videojs-components/p2p-info-button.ts @@ -1,4 +1,4 @@ -import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' +import { PlayerNetworkInfo, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' import { bytes } from '../utils' const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') @@ -65,7 +65,7 @@ class P2pInfoButton extends Button { subDivHttp.appendChild(subDivHttpText) div.appendChild(subDivHttp) - this.player_.on('p2pInfo', (event: any, data: any) => { + this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { // We are in HTTP fallback if (!data) { subDivHttp.className = 'vjs-peertube-displayed' @@ -74,11 +74,13 @@ class P2pInfoButton extends Button { return } - const downloadSpeed = bytes(data.downloadSpeed) - const uploadSpeed = bytes(data.uploadSpeed) - const totalDownloaded = bytes(data.downloaded) - const totalUploaded = bytes(data.uploaded) - const numPeers = data.numPeers + const p2pStats = data.p2p + + const downloadSpeed = bytes(p2pStats.downloadSpeed) + const uploadSpeed = bytes(p2pStats.uploadSpeed) + const totalDownloaded = bytes(p2pStats.downloaded) + const totalUploaded = bytes(p2pStats.uploaded) + const numPeers = p2pStats.numPeers subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts index 2847de470..abcc16411 100644 --- a/client/src/assets/player/videojs-components/resolution-menu-button.ts +++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts @@ -14,11 +14,9 @@ class ResolutionMenuButton extends MenuButton { super(player, options) this.player = player - player.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data)) + player.tech_.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data)) - if (player.webtorrent) { - player.webtorrent().on('videoFileUpdate', () => setTimeout(() => this.trigger('updateLabel'), 0)) - } + player.peertube().on('resolutionChange', () => setTimeout(() => this.trigger('updateLabel'), 0)) } createEl () { @@ -49,11 +47,32 @@ class ResolutionMenuButton extends MenuButton { return 'vjs-resolution-control ' + super.buildWrapperCSSClass() } + private addClickListener (component: any) { + component.on('click', () => { + let children = this.menu.children() + + for (const child of children) { + if (component !== child) { + child.selected(false) + } + } + }) + } + private buildQualities (data: LoadedQualityData) { // The automatic resolution item will need other labels const labels: { [ id: number ]: string } = {} + data.qualityData.video.sort((a, b) => { + if (a.id > b.id) return -1 + if (a.id === b.id) return 0 + return 1 + }) + for (const d of data.qualityData.video) { + // Skip auto resolution, we'll add it ourselves + if (d.id === -1) continue + this.menu.addChild(new ResolutionMenuItem( this.player_, { @@ -77,6 +96,12 @@ class ResolutionMenuButton extends MenuButton { selected: true // By default, in auto mode } )) + + for (const m of this.menu.children()) { + this.addClickListener(m) + } + + this.trigger('menuChanged') } } ResolutionMenuButton.prototype.controlText_ = 'Quality' diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts index cc1c79739..6c42fefd2 100644 --- a/client/src/assets/player/videojs-components/resolution-menu-item.ts +++ b/client/src/assets/player/videojs-components/resolution-menu-item.ts @@ -28,16 +28,12 @@ class ResolutionMenuItem extends MenuItem { this.id = options.id this.callback = options.callback - if (player.webtorrent) { - player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data)) + player.peertube().on('resolutionChange', (_: any, data: ResolutionUpdateData) => this.updateSelection(data)) - // We only want to disable the "Auto" item - if (this.id === -1) { - player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data)) - } + // We only want to disable the "Auto" item + if (this.id === -1) { + player.peertube().on('autoResolutionChange', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data)) } - - // TODO: update on HLS change } handleClick (event: any) { @@ -46,12 +42,12 @@ class ResolutionMenuItem extends MenuItem { super.handleClick(event) - this.callback(this.id) + this.callback(this.id, 'video') } updateSelection (data: ResolutionUpdateData) { if (this.id === -1) { - this.currentResolutionLabel = this.labels[data.resolutionId] + this.currentResolutionLabel = this.labels[data.id] } // Automatic resolution only @@ -60,7 +56,7 @@ class ResolutionMenuItem extends MenuItem { return } - this.selected(this.id === data.resolutionId) + this.selected(this.id === data.id) } updateAutoResolution (data: AutoResolutionUpdateData) { diff --git a/client/src/assets/player/videojs-components/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts index b9a430290..f14959f9c 100644 --- a/client/src/assets/player/videojs-components/settings-menu-item.ts +++ b/client/src/assets/player/videojs-components/settings-menu-item.ts @@ -223,6 +223,11 @@ class SettingsMenuItem extends MenuItem { this.subMenu.on('updateLabel', () => { this.update() }) + this.subMenu.on('menuChanged', () => { + this.bindClickEvents() + this.setSize() + this.update() + }) this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) @@ -230,7 +235,7 @@ class SettingsMenuItem extends MenuItem { this.update() this.createBackButton() - this.getSize() + this.setSize() this.bindClickEvents() // prefixed event listeners for CSS TransitionEnd @@ -292,8 +297,9 @@ class SettingsMenuItem extends MenuItem { // save size of submenus on first init // if number of submenu items change dynamically more logic will be needed - getSize () { + setSize () { this.dialog.removeClass('vjs-hidden') + videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) this.setMargin() this.dialog.addClass('vjs-hidden') diff --git a/client/src/assets/player/webtorrent-plugin.ts b/client/src/assets/player/webtorrent-plugin.ts index c3d990aed..47f169e24 100644 --- a/client/src/assets/player/webtorrent-plugin.ts +++ b/client/src/assets/player/webtorrent-plugin.ts @@ -5,7 +5,7 @@ import * as videojs from 'video.js' import * as WebTorrent from 'webtorrent' import { VideoFile } from '../../../../shared/models/videos/video.model' import { renderVideo } from './webtorrent/video-renderer' -import { LoadedQualityData, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings' +import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings' import { videoFileMaxByResolution, videoFileMinByResolution } from './utils' import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store' import { @@ -180,7 +180,7 @@ class WebTorrentPlugin extends Plugin { }) this.changeQuality() - this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id }) + this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id }) } updateResolution (resolutionId: number, delay = 0) { @@ -216,15 +216,15 @@ class WebTorrentPlugin extends Plugin { enableAutoResolution () { this.autoResolution = true - this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) + this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) } disableAutoResolution (forbid = false) { if (forbid === true) this.autoResolutionPossible = false this.autoResolution = false - this.trigger('autoResolutionUpdate', { possible: this.autoResolutionPossible }) - this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) + this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible }) + this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) } getTorrent () { @@ -472,12 +472,14 @@ class WebTorrentPlugin extends Plugin { if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) return this.player.trigger('p2pInfo', { - downloadSpeed: this.torrent.downloadSpeed, - numPeers: this.torrent.numPeers, - uploadSpeed: this.torrent.uploadSpeed, - downloaded: this.torrent.downloaded, - uploaded: this.torrent.uploaded - }) + p2p: { + downloadSpeed: this.torrent.downloadSpeed, + numPeers: this.torrent.numPeers, + uploadSpeed: this.torrent.uploadSpeed, + downloaded: this.torrent.downloaded, + uploaded: this.torrent.uploaded + } + } as PlayerNetworkInfo) }, this.CONSTANTS.INFO_SCHEDULER) } @@ -597,7 +599,7 @@ class WebTorrentPlugin extends Plugin { video: qualityLevelsPayload } } - this.player.trigger('loadedqualitydata', payload) + this.player.tech_.trigger('loadedqualitydata', payload) } private buildQualityLabel (file: VideoFile) { -- cgit v1.2.3 From 4348a27d252a3349bafa7ef4859c0e2cf060c255 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 24 Jan 2019 13:43:44 +0100 Subject: Add lazy loading in player --- .../src/assets/player/p2p-media-loader-plugin.ts | 11 +++++----- .../src/assets/player/peertube-player-manager.ts | 24 +++++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader-plugin.ts index 25117e51e..a5b20219f 100644 --- a/client/src/assets/player/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/p2p-media-loader-plugin.ts @@ -5,10 +5,9 @@ import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterfa // videojs-hlsjs-plugin needs videojs in window window['videojs'] = videojs -import '@streamroot/videojs-hlsjs-plugin' +require('@streamroot/videojs-hlsjs-plugin') import { Engine, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' -import * as Hls from 'hls.js' import { Events } from 'p2p-media-loader-core' const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') @@ -18,7 +17,7 @@ class P2pMediaLoaderPlugin extends Plugin { INFO_SCHEDULER: 1000 // Don't change this } - private hlsjs: Hls + private hlsjs: any // Don't type hlsjs to not bundle the module private p2pEngine: Engine private statsP2PBytes = { pendingDownload: [] as number[], @@ -33,7 +32,7 @@ class P2pMediaLoaderPlugin extends Plugin { constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { super(player, options) - videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: Hls) => { + videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { this.hlsjs = hlsjs this.initialize() @@ -54,7 +53,9 @@ class P2pMediaLoaderPlugin extends Plugin { private initialize () { this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine() - this.hlsjs.on(Hls.Events.LEVEL_SWITCHING, (_, data: Hls.levelSwitchingData) => { + // Avoid using constants to not import hls.hs + // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37 + this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => { this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height }) }) diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 2e090847c..91ca6a2aa 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -15,7 +15,6 @@ import './videojs-components/theater-button' import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings' import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' -import { Engine } from 'p2p-media-loader-hlsjs' // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' @@ -32,6 +31,7 @@ export type WebtorrentOptions = { export type P2PMediaLoaderOptions = { playlistUrl: string + trackerAnnounce: string[] } export type CommonOptions = { @@ -88,10 +88,17 @@ export class PeertubePlayerManager { } static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { + let p2pMediaLoader: any + if (mode === 'webtorrent') await import('./webtorrent-plugin') - if (mode === 'p2p-media-loader') await import('./p2p-media-loader-plugin') + if (mode === 'p2p-media-loader') { + [ p2pMediaLoader ] = await Promise.all([ + import('p2p-media-loader-hlsjs'), + import('./p2p-media-loader-plugin') + ]) + } - const videojsOptions = this.getVideojsOptions(mode, options) + const videojsOptions = this.getVideojsOptions(mode, options, p2pMediaLoader) await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language) @@ -133,7 +140,7 @@ export class PeertubePlayerManager { return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json)) } - private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions) { + private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions, p2pMediaLoaderModule?: any) { const commonOptions = options.common const webtorrentOptions = options.webtorrent const p2pMediaLoaderOptions = options.p2pMediaLoader @@ -157,16 +164,19 @@ export class PeertubePlayerManager { src: p2pMediaLoaderOptions.playlistUrl } - const config = { + const p2pMediaLoaderConfig = { + // loader: { + // trackerAnnounce: p2pMediaLoaderOptions.trackerAnnounce + // }, segments: { - swarmId: 'swarm' // TODO: choose swarm id + swarmId: p2pMediaLoaderOptions.playlistUrl } } const streamrootHls = { html5: { hlsjsConfig: { liveSyncDurationCount: 7, - loader: new Engine(config).createLoaderClass() + loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() } } } -- cgit v1.2.3 From 092092969633bbcf6d4891a083ea497a7d5c3154 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 29 Jan 2019 08:37:25 +0100 Subject: Add hls support on server --- .../src/assets/player/p2p-media-loader-plugin.ts | 104 ---- .../p2p-media-loader/p2p-media-loader-plugin.ts | 135 +++++ .../player/p2p-media-loader/segment-url-builder.ts | 28 + .../player/p2p-media-loader/segment-validator.ts | 56 ++ .../src/assets/player/peertube-player-manager.ts | 45 +- client/src/assets/player/peertube-plugin.ts | 4 +- .../src/assets/player/peertube-videojs-typings.ts | 15 +- client/src/assets/player/utils.ts | 14 + .../player/videojs-components/p2p-info-button.ts | 11 +- client/src/assets/player/webtorrent-plugin.ts | 642 --------------------- .../assets/player/webtorrent/webtorrent-plugin.ts | 639 ++++++++++++++++++++ 11 files changed, 930 insertions(+), 763 deletions(-) delete mode 100644 client/src/assets/player/p2p-media-loader-plugin.ts create mode 100644 client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts create mode 100644 client/src/assets/player/p2p-media-loader/segment-url-builder.ts create mode 100644 client/src/assets/player/p2p-media-loader/segment-validator.ts delete mode 100644 client/src/assets/player/webtorrent-plugin.ts create mode 100644 client/src/assets/player/webtorrent/webtorrent-plugin.ts (limited to 'client/src/assets') diff --git a/client/src/assets/player/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader-plugin.ts deleted file mode 100644 index a5b20219f..000000000 --- a/client/src/assets/player/p2p-media-loader-plugin.ts +++ /dev/null @@ -1,104 +0,0 @@ -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import * as videojs from 'video.js' -import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from './peertube-videojs-typings' - -// videojs-hlsjs-plugin needs videojs in window -window['videojs'] = videojs -require('@streamroot/videojs-hlsjs-plugin') - -import { Engine, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' -import { Events } from 'p2p-media-loader-core' - -const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') -class P2pMediaLoaderPlugin extends Plugin { - - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000 // Don't change this - } - - private hlsjs: any // Don't type hlsjs to not bundle the module - private p2pEngine: Engine - private statsP2PBytes = { - pendingDownload: [] as number[], - pendingUpload: [] as number[], - numPeers: 0, - totalDownload: 0, - totalUpload: 0 - } - - private networkInfoInterval: any - - constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { - super(player, options) - - videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { - this.hlsjs = hlsjs - - this.initialize() - }) - - initVideoJsContribHlsJsPlayer(player) - - player.src({ - type: options.type, - src: options.src - }) - } - - dispose () { - clearInterval(this.networkInfoInterval) - } - - private initialize () { - this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine() - - // Avoid using constants to not import hls.hs - // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37 - this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => { - this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height }) - }) - - this.runStats() - } - - private runStats () { - this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => { - if (method === 'p2p') { - this.statsP2PBytes.pendingDownload.push(size) - this.statsP2PBytes.totalDownload += size - } - }) - - this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => { - if (method === 'p2p') { - this.statsP2PBytes.pendingUpload.push(size) - this.statsP2PBytes.totalUpload += size - } - }) - - this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++) - this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--) - - this.networkInfoInterval = setInterval(() => { - let downloadSpeed = this.statsP2PBytes.pendingDownload.reduce((a: number, b: number) => a + b, 0) - let uploadSpeed = this.statsP2PBytes.pendingUpload.reduce((a: number, b: number) => a + b, 0) - - this.statsP2PBytes.pendingDownload = [] - this.statsP2PBytes.pendingUpload = [] - - return this.player.trigger('p2pInfo', { - p2p: { - downloadSpeed, - uploadSpeed, - numPeers: this.statsP2PBytes.numPeers, - downloaded: this.statsP2PBytes.totalDownload, - uploaded: this.statsP2PBytes.totalUpload - } - } as PlayerNetworkInfo) - }, this.CONSTANTS.INFO_SCHEDULER) - } -} - -videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) -export { P2pMediaLoaderPlugin } diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts new file mode 100644 index 000000000..f9a2707fb --- /dev/null +++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts @@ -0,0 +1,135 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' +import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings' +import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' +import { Events } from 'p2p-media-loader-core' + +// videojs-hlsjs-plugin needs videojs in window +window['videojs'] = videojs +require('@streamroot/videojs-hlsjs-plugin') + +const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') +class P2pMediaLoaderPlugin extends Plugin { + + private readonly CONSTANTS = { + INFO_SCHEDULER: 1000 // Don't change this + } + private readonly options: P2PMediaLoaderPluginOptions + + private hlsjs: any // Don't type hlsjs to not bundle the module + private p2pEngine: Engine + private statsP2PBytes = { + pendingDownload: [] as number[], + pendingUpload: [] as number[], + numPeers: 0, + totalDownload: 0, + totalUpload: 0 + } + private statsHTTPBytes = { + pendingDownload: [] as number[], + pendingUpload: [] as number[], + totalDownload: 0, + totalUpload: 0 + } + + private networkInfoInterval: any + + constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) { + super(player, options) + + this.options = options + + videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { + this.hlsjs = hlsjs + }) + + initVideoJsContribHlsJsPlayer(player) + + player.src({ + type: options.type, + src: options.src + }) + + player.ready(() => this.initialize()) + } + + dispose () { + clearInterval(this.networkInfoInterval) + } + + private initialize () { + initHlsJsPlayer(this.hlsjs) + + this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine() + + // Avoid using constants to not import hls.hs + // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37 + this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => { + this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height }) + }) + + this.p2pEngine.on(Events.SegmentError, (segment, err) => { + console.error('Segment error.', segment, err) + }) + + this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length + + this.runStats() + } + + private runStats () { + this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => { + const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes + + elem.pendingDownload.push(size) + elem.totalDownload += size + }) + + this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => { + const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes + + elem.pendingUpload.push(size) + elem.totalUpload += size + }) + + this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++) + this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--) + + this.networkInfoInterval = setInterval(() => { + const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload) + const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload) + + const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload) + const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload) + + this.statsP2PBytes.pendingDownload = [] + this.statsP2PBytes.pendingUpload = [] + this.statsHTTPBytes.pendingDownload = [] + this.statsHTTPBytes.pendingUpload = [] + + return this.player.trigger('p2pInfo', { + http: { + downloadSpeed: httpDownloadSpeed, + uploadSpeed: httpUploadSpeed, + downloaded: this.statsHTTPBytes.totalDownload, + uploaded: this.statsHTTPBytes.totalUpload + }, + p2p: { + downloadSpeed: p2pDownloadSpeed, + uploadSpeed: p2pUploadSpeed, + numPeers: this.statsP2PBytes.numPeers, + downloaded: this.statsP2PBytes.totalDownload, + uploaded: this.statsP2PBytes.totalUpload + } + } as PlayerNetworkInfo) + }, this.CONSTANTS.INFO_SCHEDULER) + } + + private arraySum (data: number[]) { + return data.reduce((a: number, b: number) => a + b, 0) + } +} + +videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) +export { P2pMediaLoaderPlugin } diff --git a/client/src/assets/player/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts new file mode 100644 index 000000000..32e7ce4f2 --- /dev/null +++ b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts @@ -0,0 +1,28 @@ +import { basename } from 'path' +import { Segment } from 'p2p-media-loader-core' + +function segmentUrlBuilderFactory (baseUrls: string[]) { + return function segmentBuilder (segment: Segment) { + const max = baseUrls.length + 1 + const i = getRandomInt(max) + + if (i === max - 1) return segment.url + + let newBaseUrl = baseUrls[i] + let middlePart = newBaseUrl.endsWith('/') ? '' : '/' + + return newBaseUrl + middlePart + basename(segment.url) + } +} + +// --------------------------------------------------------------------------- + +export { + segmentUrlBuilderFactory +} + +// --------------------------------------------------------------------------- + +function getRandomInt (max: number) { + return Math.floor(Math.random() * Math.floor(max)) +} diff --git a/client/src/assets/player/p2p-media-loader/segment-validator.ts b/client/src/assets/player/p2p-media-loader/segment-validator.ts new file mode 100644 index 000000000..8f4922daa --- /dev/null +++ b/client/src/assets/player/p2p-media-loader/segment-validator.ts @@ -0,0 +1,56 @@ +import { Segment } from 'p2p-media-loader-core' +import { basename } from 'path' + +function segmentValidatorFactory (segmentsSha256Url: string) { + const segmentsJSON = fetchSha256Segments(segmentsSha256Url) + + return async function segmentValidator (segment: Segment) { + const segmentName = basename(segment.url) + + const hashShouldBe = (await segmentsJSON)[segmentName] + if (hashShouldBe === undefined) { + throw new Error(`Unknown segment name ${segmentName} in segment validator`) + } + + const calculatedSha = bufferToEx(await sha256(segment.data)) + if (calculatedSha !== hashShouldBe) { + throw new Error(`Hashes does not correspond for segment ${segmentName} (expected: ${hashShouldBe} instead of ${calculatedSha})`) + } + } +} + +// --------------------------------------------------------------------------- + +export { + segmentValidatorFactory +} + +// --------------------------------------------------------------------------- + +function fetchSha256Segments (url: string) { + return fetch(url) + .then(res => res.json()) + .catch(err => { + console.error('Cannot get sha256 segments', err) + return {} + }) +} + +function sha256 (data?: ArrayBuffer) { + if (!data) return undefined + + return window.crypto.subtle.digest('SHA-256', data) +} + +// Thanks: https://stackoverflow.com/a/53307879 +function bufferToEx (buffer?: ArrayBuffer) { + if (!buffer) return '' + + let s = '' + const h = '0123456789abcdef' + const o = new Uint8Array(buffer) + + o.forEach((v: any) => s += h[ v >> 4 ] + h[ v & 15 ]) + + return s +} diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 91ca6a2aa..3fdba6fdf 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -13,8 +13,10 @@ import './videojs-components/p2p-info-button' import './videojs-components/peertube-load-progress-bar' import './videojs-components/theater-button' import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings' -import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' +import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils' import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' +import { segmentValidatorFactory } from './p2p-media-loader/segment-validator' +import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder' // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' @@ -31,7 +33,10 @@ export type WebtorrentOptions = { export type P2PMediaLoaderOptions = { playlistUrl: string + segmentsSha256Url: string trackerAnnounce: string[] + redundancyBaseUrls: string[] + videoFiles: VideoFile[] } export type CommonOptions = { @@ -90,11 +95,11 @@ export class PeertubePlayerManager { static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { let p2pMediaLoader: any - if (mode === 'webtorrent') await import('./webtorrent-plugin') + if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') if (mode === 'p2p-media-loader') { [ p2pMediaLoader ] = await Promise.all([ import('p2p-media-loader-hlsjs'), - import('./p2p-media-loader-plugin') + import('./p2p-media-loader/p2p-media-loader-plugin') ]) } @@ -144,11 +149,14 @@ export class PeertubePlayerManager { const commonOptions = options.common const webtorrentOptions = options.webtorrent const p2pMediaLoaderOptions = options.p2pMediaLoader + + let autoplay = options.common.autoplay let html5 = {} const plugins: VideoJSPluginOptions = { peertube: { - autoplay: commonOptions.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent + mode, + autoplay, // Use peertube plugin autoplay because we get the file by webtorrent videoViewUrl: commonOptions.videoViewUrl, videoDuration: commonOptions.videoDuration, startTime: commonOptions.startTime, @@ -160,19 +168,35 @@ export class PeertubePlayerManager { if (p2pMediaLoaderOptions) { const p2pMediaLoader: P2PMediaLoaderPluginOptions = { + redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, type: 'application/x-mpegURL', src: p2pMediaLoaderOptions.playlistUrl } + const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce + .filter(t => t.startsWith('ws')) + const p2pMediaLoaderConfig = { - // loader: { - // trackerAnnounce: p2pMediaLoaderOptions.trackerAnnounce - // }, + loader: { + trackerAnnounce, + segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url), + rtcConfig: getRtcConfig(), + requiredSegmentsPriority: 5, + segmentUrlBuilder: segmentUrlBuilderFactory(options.p2pMediaLoader.redundancyBaseUrls) + }, segments: { swarmId: p2pMediaLoaderOptions.playlistUrl } } const streamrootHls = { + levelLabelHandler: (level: { height: number, width: number }) => { + const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === level.height) + + let label = file.resolution.label + if (file.fps >= 50) label += file.fps + + return label + }, html5: { hlsjsConfig: { liveSyncDurationCount: 7, @@ -187,12 +211,15 @@ export class PeertubePlayerManager { if (webtorrentOptions) { const webtorrent = { - autoplay: commonOptions.autoplay, + autoplay, videoDuration: commonOptions.videoDuration, playerElement: commonOptions.playerElement, videoFiles: webtorrentOptions.videoFiles } Object.assign(plugins, { webtorrent }) + + // WebTorrent plugin handles autoplay, because we do some hackish stuff in there + autoplay = false } const videojsOptions = { @@ -208,7 +235,7 @@ export class PeertubePlayerManager { : undefined, // Undefined so the player knows it has to check the local storage poster: commonOptions.poster, - autoplay: false, + autoplay, inactivityTimeout: commonOptions.inactivityTimeout, playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], plugins, diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index f83d9094a..aacbf5f6e 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts @@ -52,12 +52,12 @@ class PeerTubePlugin extends Plugin { this.player.ready(() => { const playerOptions = this.player.options_ - if (this.player.webtorrent) { + if (options.mode === 'webtorrent') { this.player.webtorrent().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) this.player.webtorrent().on('autoResolutionChange', (_: any, d: any) => this.trigger('autoResolutionChange', d)) } - if (this.player.p2pMediaLoader) { + if (options.mode === 'p2p-media-loader') { this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d)) } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index fff992a6f..79a5a6c4d 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -4,12 +4,15 @@ import * as videojs from 'video.js' import { VideoFile } from '../../../../shared/models/videos/video.model' import { PeerTubePlugin } from './peertube-plugin' -import { WebTorrentPlugin } from './webtorrent-plugin' +import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin' +import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' +import { PlayerMode } from './peertube-player-manager' declare namespace videojs { interface Player { peertube (): PeerTubePlugin webtorrent (): WebTorrentPlugin + p2pMediaLoader (): P2pMediaLoaderPlugin } } @@ -33,6 +36,8 @@ type UserWatching = { } type PeerTubePluginOptions = { + mode: PlayerMode + autoplay: boolean videoViewUrl: string videoDuration: number @@ -54,6 +59,7 @@ type WebtorrentPluginOptions = { } type P2PMediaLoaderPluginOptions = { + redundancyBaseUrls: string[] type: string src: string } @@ -91,6 +97,13 @@ type AutoResolutionUpdateData = { } type PlayerNetworkInfo = { + http: { + downloadSpeed: number + uploadSpeed: number + downloaded: number + uploaded: number + } + p2p: { downloadSpeed: number uploadSpeed: number diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index 8b9f34b99..8d87567c2 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -112,9 +112,23 @@ function videoFileMinByResolution (files: VideoFile[]) { return min } +function getRtcConfig () { + return { + iceServers: [ + { + urls: 'stun:stun.stunprotocol.org' + }, + { + urls: 'stun:stun.framasoft.org' + } + ] + } +} + // --------------------------------------------------------------------------- export { + getRtcConfig, toTitleCase, timeToInt, buildVideoLink, diff --git a/client/src/assets/player/videojs-components/p2p-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts index 2fc4c4562..6424787b2 100644 --- a/client/src/assets/player/videojs-components/p2p-info-button.ts +++ b/client/src/assets/player/videojs-components/p2p-info-button.ts @@ -75,11 +75,12 @@ class P2pInfoButton extends Button { } const p2pStats = data.p2p + const httpStats = data.http - const downloadSpeed = bytes(p2pStats.downloadSpeed) - const uploadSpeed = bytes(p2pStats.uploadSpeed) - const totalDownloaded = bytes(p2pStats.downloaded) - const totalUploaded = bytes(p2pStats.uploaded) + const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed) + const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed) + const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded) + const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded) const numPeers = p2pStats.numPeers subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + @@ -92,7 +93,7 @@ class P2pInfoButton extends Button { uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] peersNumber.textContent = numPeers - peersText.textContent = ' ' + this.player_.localize('peers') + peersText.textContent = ' ' + (numPeers > 1 ? this.player_.localize('peers') : this.player_.localize('peer')) subDivHttp.className = 'vjs-peertube-hidden' subDivWebtorrent.className = 'vjs-peertube-displayed' diff --git a/client/src/assets/player/webtorrent-plugin.ts b/client/src/assets/player/webtorrent-plugin.ts deleted file mode 100644 index 47f169e24..000000000 --- a/client/src/assets/player/webtorrent-plugin.ts +++ /dev/null @@ -1,642 +0,0 @@ -// FIXME: something weird with our path definition in tsconfig and typings -// @ts-ignore -import * as videojs from 'video.js' - -import * as WebTorrent from 'webtorrent' -import { VideoFile } from '../../../../shared/models/videos/video.model' -import { renderVideo } from './webtorrent/video-renderer' -import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings' -import { videoFileMaxByResolution, videoFileMinByResolution } from './utils' -import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store' -import { - getAverageBandwidthInStore, - getStoredMute, - getStoredVolume, - getStoredWebTorrentEnabled, - saveAverageBandwidth -} from './peertube-player-local-storage' - -const CacheChunkStore = require('cache-chunk-store') - -type PlayOptions = { - forcePlay?: boolean, - seek?: number, - delay?: number -} - -const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') -class WebTorrentPlugin extends Plugin { - private readonly playerElement: HTMLVideoElement - - private readonly autoplay: boolean = false - private readonly startTime: number = 0 - private readonly savePlayerSrcFunction: Function - private readonly videoFiles: VideoFile[] - private readonly videoDuration: number - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000, // Don't change this - AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds - AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it - AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check - AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds - BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth - } - - private readonly webtorrent = new WebTorrent({ - tracker: { - rtcConfig: { - iceServers: [ - { - urls: 'stun:stun.stunprotocol.org' - }, - { - urls: 'stun:stun.framasoft.org' - } - ] - } - }, - dht: false - }) - - private player: any - private currentVideoFile: VideoFile - private torrent: WebTorrent.Torrent - - private renderer: any - private fakeRenderer: any - private destroyingFakeRenderer = false - - private autoResolution = true - private autoResolutionPossible = true - private isAutoResolutionObservation = false - private playerRefusedP2P = false - - private torrentInfoInterval: any - private autoQualityInterval: any - private addTorrentDelay: any - private qualityObservationTimer: any - private runAutoQualitySchedulerTimer: any - - private downloadSpeeds: number[] = [] - - constructor (player: videojs.Player, options: WebtorrentPluginOptions) { - super(player, options) - - // Disable auto play on iOS - this.autoplay = options.autoplay && this.isIOS() === false - this.playerRefusedP2P = !getStoredWebTorrentEnabled() - - this.videoFiles = options.videoFiles - this.videoDuration = options.videoDuration - - this.savePlayerSrcFunction = this.player.src - this.playerElement = options.playerElement - - this.player.ready(() => { - const playerOptions = this.player.options_ - - const volume = getStoredVolume() - if (volume !== undefined) this.player.volume(volume) - - const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() - if (muted !== undefined) this.player.muted(muted) - - this.player.duration(options.videoDuration) - - this.initializePlayer() - this.runTorrentInfoScheduler() - - this.player.one('play', () => { - // Don't run immediately scheduler, wait some seconds the TCP connections are made - this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - }) - }) - } - - dispose () { - clearTimeout(this.addTorrentDelay) - clearTimeout(this.qualityObservationTimer) - clearTimeout(this.runAutoQualitySchedulerTimer) - - clearInterval(this.torrentInfoInterval) - clearInterval(this.autoQualityInterval) - - // Don't need to destroy renderer, video player will be destroyed - this.flushVideoFile(this.currentVideoFile, false) - - this.destroyFakeRenderer() - } - - getCurrentResolutionId () { - return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 - } - - updateVideoFile ( - videoFile?: VideoFile, - options: { - forcePlay?: boolean, - seek?: number, - delay?: number - } = {}, - done: () => void = () => { /* empty */ } - ) { - // Automatically choose the adapted video file - if (videoFile === undefined) { - const savedAverageBandwidth = getAverageBandwidthInStore() - videoFile = savedAverageBandwidth - ? this.getAppropriateFile(savedAverageBandwidth) - : this.pickAverageVideoFile() - } - - // Don't add the same video file once again - if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { - return - } - - // Do not display error to user because we will have multiple fallback - this.disableErrorDisplay() - - // Hack to "simulate" src link in video.js >= 6 - // Without this, we can't play the video after pausing it - // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 - this.player.src = () => true - const oldPlaybackRate = this.player.playbackRate() - - const previousVideoFile = this.currentVideoFile - this.currentVideoFile = videoFile - - // Don't try on iOS that does not support MediaSource - // Or don't use P2P if webtorrent is disabled - if (this.isIOS() || this.playerRefusedP2P) { - return this.fallbackToHttp(options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - } - - this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - - this.changeQuality() - this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id }) - } - - updateResolution (resolutionId: number, delay = 0) { - // Remember player state - const currentTime = this.player.currentTime() - const isPaused = this.player.paused() - - // Remove poster to have black background - this.playerElement.poster = '' - - // Hide bigPlayButton - if (!isPaused) { - this.player.bigPlayButton.hide() - } - - const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) - const options = { - forcePlay: false, - delay, - seek: currentTime + (delay / 1000) - } - this.updateVideoFile(newVideoFile, options) - } - - flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { - if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { - if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() - - this.webtorrent.remove(videoFile.magnetUri) - console.log('Removed ' + videoFile.magnetUri) - } - } - - enableAutoResolution () { - this.autoResolution = true - this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) - } - - disableAutoResolution (forbid = false) { - if (forbid === true) this.autoResolutionPossible = false - - this.autoResolution = false - this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible }) - this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) - } - - getTorrent () { - return this.torrent - } - - private addTorrent ( - magnetOrTorrentUrl: string, - previousVideoFile: VideoFile, - options: PlayOptions, - done: Function - ) { - console.log('Adding ' + magnetOrTorrentUrl + '.') - - const oldTorrent = this.torrent - const torrentOptions = { - store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { - max: 100 - }) - } - - this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { - console.log('Added ' + magnetOrTorrentUrl + '.') - - if (oldTorrent) { - // Pause the old torrent - this.stopTorrent(oldTorrent) - - // We use a fake renderer so we download correct pieces of the next file - if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay) - } - - // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) - this.addTorrentDelay = setTimeout(() => { - // We don't need the fake renderer anymore - this.destroyFakeRenderer() - - const paused = this.player.paused() - - this.flushVideoFile(previousVideoFile) - - // Update progress bar (just for the UI), do not wait rendering - if (options.seek) this.player.currentTime(options.seek) - - const renderVideoOptions = { autoplay: false, controls: true } - renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { - this.renderer = renderer - - if (err) return this.fallbackToHttp(options, done) - - return this.tryToPlay(err => { - if (err) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - return done() - }) - }) - }, options.delay || 0) - }) - - this.torrent.on('error', (err: any) => console.error(err)) - - this.torrent.on('warning', (err: any) => { - // We don't support HTTP tracker but we don't care -> we use the web socket tracker - if (err.message.indexOf('Unsupported tracker protocol') !== -1) return - - // Users don't care about issues with WebRTC, but developers do so log it in the console - if (err.message.indexOf('Ice connection failed') !== -1) { - console.log(err) - return - } - - // Magnet hash is not up to date with the torrent file, add directly the torrent file - if (err.message.indexOf('incorrect info hash') !== -1) { - console.error('Incorrect info hash detected, falling back to torrent file.') - const newOptions = { forcePlay: true, seek: options.seek } - return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done) - } - - // Remote instance is down - if (err.message.indexOf('from xs param') !== -1) { - this.handleError(err) - } - - console.warn(err) - }) - } - - private tryToPlay (done?: (err?: Error) => void) { - if (!done) done = function () { /* empty */ } - - const playPromise = this.player.play() - if (playPromise !== undefined) { - return playPromise.then(done) - .catch((err: Error) => { - if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) { - return - } - - console.error(err) - this.player.pause() - this.player.posterImage.show() - this.player.removeClass('vjs-has-autoplay') - this.player.removeClass('vjs-has-big-play-button-clicked') - - return done() - }) - } - - return done() - } - - private seek (time: number) { - this.player.currentTime(time) - this.player.handleTechSeeked_() - } - - private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { - if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined - if (this.videoFiles.length === 1) return this.videoFiles[0] - - // Don't change the torrent is the play was ended - if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile - - if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() - - // Limit resolution according to player height - const playerHeight = this.playerElement.offsetHeight as number - - // We take the first resolution just above the player height - // Example: player height is 530px, we want the 720p file instead of 480p - let maxResolution = this.videoFiles[0].resolution.id - for (let i = this.videoFiles.length - 1; i >= 0; i--) { - const resolutionId = this.videoFiles[i].resolution.id - if (resolutionId >= playerHeight) { - maxResolution = resolutionId - break - } - } - - // Filter videos we can play according to our screen resolution and bandwidth - const filteredFiles = this.videoFiles - .filter(f => f.resolution.id <= maxResolution) - .filter(f => { - const fileBitrate = (f.size / this.videoDuration) - let threshold = fileBitrate - - // If this is for a higher resolution or an initial load: add a margin - if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { - threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) - } - - return averageDownloadSpeed > threshold - }) - - // If the download speed is too bad, return the lowest resolution we have - if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles) - - return videoFileMaxByResolution(filteredFiles) - } - - private getAndSaveActualDownloadSpeed () { - const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) - const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) - if (lastDownloadSpeeds.length === 0) return -1 - - const sum = lastDownloadSpeeds.reduce((a, b) => a + b) - const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) - - // Save the average bandwidth for future use - saveAverageBandwidth(averageBandwidth) - - return averageBandwidth - } - - private initializePlayer () { - this.buildQualities() - - if (this.autoplay === true) { - this.player.posterImage.hide() - - return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - - // Proxy first play - const oldPlay = this.player.play.bind(this.player) - this.player.play = () => { - this.player.addClass('vjs-has-big-play-button-clicked') - this.player.play = oldPlay - - this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - } - - private runAutoQualityScheduler () { - this.autoQualityInterval = setInterval(() => { - - // Not initialized or in HTTP fallback - if (this.torrent === undefined || this.torrent === null) return - if (this.autoResolution === false) return - if (this.isAutoResolutionObservation === true) return - - const file = this.getAppropriateFile() - let changeResolution = false - let changeResolutionDelay = 0 - - // Lower resolution - if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { - console.log('Downgrading automatically the resolution to: %s', file.resolution.label) - changeResolution = true - } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution - console.log('Upgrading automatically the resolution to: %s', file.resolution.label) - changeResolution = true - changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY - } - - if (changeResolution === true) { - this.updateResolution(file.resolution.id, changeResolutionDelay) - - // Wait some seconds in observation of our new resolution - this.isAutoResolutionObservation = true - - this.qualityObservationTimer = setTimeout(() => { - this.isAutoResolutionObservation = false - }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) - } - }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - } - - private isPlayerWaiting () { - return this.player && this.player.hasClass('vjs-waiting') - } - - private runTorrentInfoScheduler () { - this.torrentInfoInterval = setInterval(() => { - // Not initialized yet - if (this.torrent === undefined) return - - // Http fallback - if (this.torrent === null) return this.player.trigger('p2pInfo', false) - - // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too - if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) - - return this.player.trigger('p2pInfo', { - p2p: { - downloadSpeed: this.torrent.downloadSpeed, - numPeers: this.torrent.numPeers, - uploadSpeed: this.torrent.uploadSpeed, - downloaded: this.torrent.downloaded, - uploaded: this.torrent.uploaded - } - } as PlayerNetworkInfo) - }, this.CONSTANTS.INFO_SCHEDULER) - } - - private fallbackToHttp (options: PlayOptions, done?: Function) { - const paused = this.player.paused() - - this.disableAutoResolution(true) - - this.flushVideoFile(this.currentVideoFile, true) - this.torrent = null - - // Enable error display now this is our last fallback - this.player.one('error', () => this.enableErrorDisplay()) - - const httpUrl = this.currentVideoFile.fileUrl - this.player.src = this.savePlayerSrcFunction - this.player.src(httpUrl) - - this.changeQuality() - - // We changed the source, so reinit captions - this.player.trigger('sourcechange') - - return this.tryToPlay(err => { - if (err && done) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - if (done) return done() - }) - } - - private handleError (err: Error | string) { - return this.player.trigger('customError', { err }) - } - - private enableErrorDisplay () { - this.player.addClass('vjs-error-display-enabled') - } - - private disableErrorDisplay () { - this.player.removeClass('vjs-error-display-enabled') - } - - private isIOS () { - return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) - } - - private pickAverageVideoFile () { - if (this.videoFiles.length === 1) return this.videoFiles[0] - - return this.videoFiles[Math.floor(this.videoFiles.length / 2)] - } - - private stopTorrent (torrent: WebTorrent.Torrent) { - torrent.pause() - // Pause does not remove actual peers (in particular the webseed peer) - torrent.removePeer(torrent[ 'ws' ]) - } - - private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { - this.destroyingFakeRenderer = false - - const fakeVideoElem = document.createElement('video') - renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { - this.fakeRenderer = renderer - - // The renderer returns an error when we destroy it, so skip them - if (this.destroyingFakeRenderer === false && err) { - console.error('Cannot render new torrent in fake video element.', err) - } - - // Load the future file at the correct time (in delay MS - 2 seconds) - fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) - }) - } - - private destroyFakeRenderer () { - if (this.fakeRenderer) { - this.destroyingFakeRenderer = true - - if (this.fakeRenderer.destroy) { - try { - this.fakeRenderer.destroy() - } catch (err) { - console.log('Cannot destroy correctly fake renderer.', err) - } - } - this.fakeRenderer = undefined - } - } - - private buildQualities () { - const qualityLevelsPayload = [] - - for (const file of this.videoFiles) { - const representation = { - id: file.resolution.id, - label: this.buildQualityLabel(file), - height: file.resolution.id, - _enabled: true - } - - this.player.qualityLevels().addQualityLevel(representation) - - qualityLevelsPayload.push({ - id: representation.id, - label: representation.label, - selected: false - }) - } - - const payload: LoadedQualityData = { - qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d), - qualityData: { - video: qualityLevelsPayload - } - } - this.player.tech_.trigger('loadedqualitydata', payload) - } - - private buildQualityLabel (file: VideoFile) { - let label = file.resolution.label - - if (file.fps && file.fps >= 50) { - label += file.fps - } - - return label - } - - private qualitySwitchCallback (id: number) { - if (id === -1) { - if (this.autoResolutionPossible === true) this.enableAutoResolution() - return - } - - this.disableAutoResolution() - this.updateResolution(id) - } - - private changeQuality () { - const resolutionId = this.currentVideoFile.resolution.id - const qualityLevels = this.player.qualityLevels() - - if (resolutionId === -1) { - qualityLevels.selectedIndex = -1 - return - } - - for (let i = 0; i < qualityLevels; i++) { - const q = this.player.qualityLevels[i] - if (q.height === resolutionId) qualityLevels.selectedIndex = i - } - } -} - -videojs.registerPlugin('webtorrent', WebTorrentPlugin) -export { WebTorrentPlugin } diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts new file mode 100644 index 000000000..c69bf31fa --- /dev/null +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts @@ -0,0 +1,639 @@ +// FIXME: something weird with our path definition in tsconfig and typings +// @ts-ignore +import * as videojs from 'video.js' + +import * as WebTorrent from 'webtorrent' +import { VideoFile } from '../../../../../shared/models/videos/video.model' +import { renderVideo } from './video-renderer' +import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' +import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' +import { PeertubeChunkStore } from './peertube-chunk-store' +import { + getAverageBandwidthInStore, + getStoredMute, + getStoredVolume, + getStoredWebTorrentEnabled, + saveAverageBandwidth +} from '../peertube-player-local-storage' + +const CacheChunkStore = require('cache-chunk-store') + +type PlayOptions = { + forcePlay?: boolean, + seek?: number, + delay?: number +} + +const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') +class WebTorrentPlugin extends Plugin { + private readonly playerElement: HTMLVideoElement + + private readonly autoplay: boolean = false + private readonly startTime: number = 0 + private readonly savePlayerSrcFunction: Function + private readonly videoFiles: VideoFile[] + private readonly videoDuration: number + private readonly CONSTANTS = { + INFO_SCHEDULER: 1000, // Don't change this + AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds + AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it + AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check + AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds + BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth + } + + private readonly webtorrent = new WebTorrent({ + tracker: { + rtcConfig: getRtcConfig() + }, + dht: false + }) + + private player: any + private currentVideoFile: VideoFile + private torrent: WebTorrent.Torrent + + private renderer: any + private fakeRenderer: any + private destroyingFakeRenderer = false + + private autoResolution = true + private autoResolutionPossible = true + private isAutoResolutionObservation = false + private playerRefusedP2P = false + + private torrentInfoInterval: any + private autoQualityInterval: any + private addTorrentDelay: any + private qualityObservationTimer: any + private runAutoQualitySchedulerTimer: any + + private downloadSpeeds: number[] = [] + + constructor (player: videojs.Player, options: WebtorrentPluginOptions) { + super(player, options) + + // Disable auto play on iOS + this.autoplay = options.autoplay && this.isIOS() === false + this.playerRefusedP2P = !getStoredWebTorrentEnabled() + + this.videoFiles = options.videoFiles + this.videoDuration = options.videoDuration + + this.savePlayerSrcFunction = this.player.src + this.playerElement = options.playerElement + + this.player.ready(() => { + const playerOptions = this.player.options_ + + const volume = getStoredVolume() + if (volume !== undefined) this.player.volume(volume) + + const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() + if (muted !== undefined) this.player.muted(muted) + + this.player.duration(options.videoDuration) + + this.initializePlayer() + this.runTorrentInfoScheduler() + + this.player.one('play', () => { + // Don't run immediately scheduler, wait some seconds the TCP connections are made + this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) + }) + }) + } + + dispose () { + clearTimeout(this.addTorrentDelay) + clearTimeout(this.qualityObservationTimer) + clearTimeout(this.runAutoQualitySchedulerTimer) + + clearInterval(this.torrentInfoInterval) + clearInterval(this.autoQualityInterval) + + // Don't need to destroy renderer, video player will be destroyed + this.flushVideoFile(this.currentVideoFile, false) + + this.destroyFakeRenderer() + } + + getCurrentResolutionId () { + return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 + } + + updateVideoFile ( + videoFile?: VideoFile, + options: { + forcePlay?: boolean, + seek?: number, + delay?: number + } = {}, + done: () => void = () => { /* empty */ } + ) { + // Automatically choose the adapted video file + if (videoFile === undefined) { + const savedAverageBandwidth = getAverageBandwidthInStore() + videoFile = savedAverageBandwidth + ? this.getAppropriateFile(savedAverageBandwidth) + : this.pickAverageVideoFile() + } + + // Don't add the same video file once again + if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { + return + } + + // Do not display error to user because we will have multiple fallback + this.disableErrorDisplay() + + // Hack to "simulate" src link in video.js >= 6 + // Without this, we can't play the video after pausing it + // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 + this.player.src = () => true + const oldPlaybackRate = this.player.playbackRate() + + const previousVideoFile = this.currentVideoFile + this.currentVideoFile = videoFile + + // Don't try on iOS that does not support MediaSource + // Or don't use P2P if webtorrent is disabled + if (this.isIOS() || this.playerRefusedP2P) { + return this.fallbackToHttp(options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + } + + this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + + this.changeQuality() + this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id }) + } + + updateResolution (resolutionId: number, delay = 0) { + // Remember player state + const currentTime = this.player.currentTime() + const isPaused = this.player.paused() + + // Remove poster to have black background + this.playerElement.poster = '' + + // Hide bigPlayButton + if (!isPaused) { + this.player.bigPlayButton.hide() + } + + const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) + const options = { + forcePlay: false, + delay, + seek: currentTime + (delay / 1000) + } + this.updateVideoFile(newVideoFile, options) + } + + flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { + if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { + if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() + + this.webtorrent.remove(videoFile.magnetUri) + console.log('Removed ' + videoFile.magnetUri) + } + } + + enableAutoResolution () { + this.autoResolution = true + this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) + } + + disableAutoResolution (forbid = false) { + if (forbid === true) this.autoResolutionPossible = false + + this.autoResolution = false + this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible }) + this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() }) + } + + getTorrent () { + return this.torrent + } + + private addTorrent ( + magnetOrTorrentUrl: string, + previousVideoFile: VideoFile, + options: PlayOptions, + done: Function + ) { + console.log('Adding ' + magnetOrTorrentUrl + '.') + + const oldTorrent = this.torrent + const torrentOptions = { + store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { + max: 100 + }) + } + + this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { + console.log('Added ' + magnetOrTorrentUrl + '.') + + if (oldTorrent) { + // Pause the old torrent + this.stopTorrent(oldTorrent) + + // We use a fake renderer so we download correct pieces of the next file + if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay) + } + + // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) + this.addTorrentDelay = setTimeout(() => { + // We don't need the fake renderer anymore + this.destroyFakeRenderer() + + const paused = this.player.paused() + + this.flushVideoFile(previousVideoFile) + + // Update progress bar (just for the UI), do not wait rendering + if (options.seek) this.player.currentTime(options.seek) + + const renderVideoOptions = { autoplay: false, controls: true } + renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => { + this.renderer = renderer + + if (err) return this.fallbackToHttp(options, done) + + return this.tryToPlay(err => { + if (err) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + return done() + }) + }) + }, options.delay || 0) + }) + + this.torrent.on('error', (err: any) => console.error(err)) + + this.torrent.on('warning', (err: any) => { + // We don't support HTTP tracker but we don't care -> we use the web socket tracker + if (err.message.indexOf('Unsupported tracker protocol') !== -1) return + + // Users don't care about issues with WebRTC, but developers do so log it in the console + if (err.message.indexOf('Ice connection failed') !== -1) { + console.log(err) + return + } + + // Magnet hash is not up to date with the torrent file, add directly the torrent file + if (err.message.indexOf('incorrect info hash') !== -1) { + console.error('Incorrect info hash detected, falling back to torrent file.') + const newOptions = { forcePlay: true, seek: options.seek } + return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done) + } + + // Remote instance is down + if (err.message.indexOf('from xs param') !== -1) { + this.handleError(err) + } + + console.warn(err) + }) + } + + private tryToPlay (done?: (err?: Error) => void) { + if (!done) done = function () { /* empty */ } + + const playPromise = this.player.play() + if (playPromise !== undefined) { + return playPromise.then(done) + .catch((err: Error) => { + if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) { + return + } + + console.error(err) + this.player.pause() + this.player.posterImage.show() + this.player.removeClass('vjs-has-autoplay') + this.player.removeClass('vjs-has-big-play-button-clicked') + + return done() + }) + } + + return done() + } + + private seek (time: number) { + this.player.currentTime(time) + this.player.handleTechSeeked_() + } + + private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { + if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined + if (this.videoFiles.length === 1) return this.videoFiles[0] + + // Don't change the torrent is the play was ended + if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile + + if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() + + // Limit resolution according to player height + const playerHeight = this.playerElement.offsetHeight as number + + // We take the first resolution just above the player height + // Example: player height is 530px, we want the 720p file instead of 480p + let maxResolution = this.videoFiles[0].resolution.id + for (let i = this.videoFiles.length - 1; i >= 0; i--) { + const resolutionId = this.videoFiles[i].resolution.id + if (resolutionId >= playerHeight) { + maxResolution = resolutionId + break + } + } + + // Filter videos we can play according to our screen resolution and bandwidth + const filteredFiles = this.videoFiles + .filter(f => f.resolution.id <= maxResolution) + .filter(f => { + const fileBitrate = (f.size / this.videoDuration) + let threshold = fileBitrate + + // If this is for a higher resolution or an initial load: add a margin + if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { + threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) + } + + return averageDownloadSpeed > threshold + }) + + // If the download speed is too bad, return the lowest resolution we have + if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles) + + return videoFileMaxByResolution(filteredFiles) + } + + private getAndSaveActualDownloadSpeed () { + const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) + const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) + if (lastDownloadSpeeds.length === 0) return -1 + + const sum = lastDownloadSpeeds.reduce((a, b) => a + b) + const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) + + // Save the average bandwidth for future use + saveAverageBandwidth(averageBandwidth) + + return averageBandwidth + } + + private initializePlayer () { + this.buildQualities() + + if (this.autoplay === true) { + this.player.posterImage.hide() + + return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) + } + + // Proxy first play + const oldPlay = this.player.play.bind(this.player) + this.player.play = () => { + this.player.addClass('vjs-has-big-play-button-clicked') + this.player.play = oldPlay + + this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) + } + } + + private runAutoQualityScheduler () { + this.autoQualityInterval = setInterval(() => { + + // Not initialized or in HTTP fallback + if (this.torrent === undefined || this.torrent === null) return + if (this.autoResolution === false) return + if (this.isAutoResolutionObservation === true) return + + const file = this.getAppropriateFile() + let changeResolution = false + let changeResolutionDelay = 0 + + // Lower resolution + if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { + console.log('Downgrading automatically the resolution to: %s', file.resolution.label) + changeResolution = true + } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution + console.log('Upgrading automatically the resolution to: %s', file.resolution.label) + changeResolution = true + changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY + } + + if (changeResolution === true) { + this.updateResolution(file.resolution.id, changeResolutionDelay) + + // Wait some seconds in observation of our new resolution + this.isAutoResolutionObservation = true + + this.qualityObservationTimer = setTimeout(() => { + this.isAutoResolutionObservation = false + }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) + } + }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) + } + + private isPlayerWaiting () { + return this.player && this.player.hasClass('vjs-waiting') + } + + private runTorrentInfoScheduler () { + this.torrentInfoInterval = setInterval(() => { + // Not initialized yet + if (this.torrent === undefined) return + + // Http fallback + if (this.torrent === null) return this.player.trigger('p2pInfo', false) + + // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too + if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) + + return this.player.trigger('p2pInfo', { + http: { + downloadSpeed: 0, + uploadSpeed: 0, + downloaded: 0, + uploaded: 0 + }, + p2p: { + downloadSpeed: this.torrent.downloadSpeed, + numPeers: this.torrent.numPeers, + uploadSpeed: this.torrent.uploadSpeed, + downloaded: this.torrent.downloaded, + uploaded: this.torrent.uploaded + } + } as PlayerNetworkInfo) + }, this.CONSTANTS.INFO_SCHEDULER) + } + + private fallbackToHttp (options: PlayOptions, done?: Function) { + const paused = this.player.paused() + + this.disableAutoResolution(true) + + this.flushVideoFile(this.currentVideoFile, true) + this.torrent = null + + // Enable error display now this is our last fallback + this.player.one('error', () => this.enableErrorDisplay()) + + const httpUrl = this.currentVideoFile.fileUrl + this.player.src = this.savePlayerSrcFunction + this.player.src(httpUrl) + + this.changeQuality() + + // We changed the source, so reinit captions + this.player.trigger('sourcechange') + + return this.tryToPlay(err => { + if (err && done) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + if (done) return done() + }) + } + + private handleError (err: Error | string) { + return this.player.trigger('customError', { err }) + } + + private enableErrorDisplay () { + this.player.addClass('vjs-error-display-enabled') + } + + private disableErrorDisplay () { + this.player.removeClass('vjs-error-display-enabled') + } + + private isIOS () { + return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) + } + + private pickAverageVideoFile () { + if (this.videoFiles.length === 1) return this.videoFiles[0] + + return this.videoFiles[Math.floor(this.videoFiles.length / 2)] + } + + private stopTorrent (torrent: WebTorrent.Torrent) { + torrent.pause() + // Pause does not remove actual peers (in particular the webseed peer) + torrent.removePeer(torrent[ 'ws' ]) + } + + private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { + this.destroyingFakeRenderer = false + + const fakeVideoElem = document.createElement('video') + renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { + this.fakeRenderer = renderer + + // The renderer returns an error when we destroy it, so skip them + if (this.destroyingFakeRenderer === false && err) { + console.error('Cannot render new torrent in fake video element.', err) + } + + // Load the future file at the correct time (in delay MS - 2 seconds) + fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) + }) + } + + private destroyFakeRenderer () { + if (this.fakeRenderer) { + this.destroyingFakeRenderer = true + + if (this.fakeRenderer.destroy) { + try { + this.fakeRenderer.destroy() + } catch (err) { + console.log('Cannot destroy correctly fake renderer.', err) + } + } + this.fakeRenderer = undefined + } + } + + private buildQualities () { + const qualityLevelsPayload = [] + + for (const file of this.videoFiles) { + const representation = { + id: file.resolution.id, + label: this.buildQualityLabel(file), + height: file.resolution.id, + _enabled: true + } + + this.player.qualityLevels().addQualityLevel(representation) + + qualityLevelsPayload.push({ + id: representation.id, + label: representation.label, + selected: false + }) + } + + const payload: LoadedQualityData = { + qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d), + qualityData: { + video: qualityLevelsPayload + } + } + this.player.tech_.trigger('loadedqualitydata', payload) + } + + private buildQualityLabel (file: VideoFile) { + let label = file.resolution.label + + if (file.fps && file.fps >= 50) { + label += file.fps + } + + return label + } + + private qualitySwitchCallback (id: number) { + if (id === -1) { + if (this.autoResolutionPossible === true) this.enableAutoResolution() + return + } + + this.disableAutoResolution() + this.updateResolution(id) + } + + private changeQuality () { + const resolutionId = this.currentVideoFile.resolution.id + const qualityLevels = this.player.qualityLevels() + + if (resolutionId === -1) { + qualityLevels.selectedIndex = -1 + return + } + + for (let i = 0; i < qualityLevels; i++) { + const q = this.player.qualityLevels[i] + if (q.height === resolutionId) qualityLevels.selectedIndex = i + } + } +} + +videojs.registerPlugin('webtorrent', WebTorrentPlugin) +export { WebTorrentPlugin } -- cgit v1.2.3 From 6ec0b75beb9c8bcd84e178912319913b91830da2 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 6 Feb 2019 10:39:50 +0100 Subject: Fallback HLS to webtorrent --- .../p2p-media-loader/p2p-media-loader-plugin.ts | 10 ++++- .../src/assets/player/peertube-player-manager.ts | 45 ++++++++++++++++++++-- client/src/assets/player/peertube-plugin.ts | 6 ++- 3 files changed, 55 insertions(+), 6 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts index f9a2707fb..022a9c16f 100644 --- a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts @@ -51,17 +51,25 @@ class P2pMediaLoaderPlugin extends Plugin { src: options.src }) + player.on('play', () => { + player.addClass('vjs-has-big-play-button-clicked') + }) + player.ready(() => this.initialize()) } dispose () { + if (this.hlsjs) this.hlsjs.destroy() + if (this.p2pEngine) this.p2pEngine.destroy() + clearInterval(this.networkInfoInterval) } private initialize () { initHlsJsPlayer(this.hlsjs) - this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine() + const tech = this.player.tech_ + this.p2pEngine = tech.options_.hlsjsConfig.loader.getEngine() // Avoid using constants to not import hls.hs // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37 diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 3fdba6fdf..0ba9bcb11 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -41,6 +41,7 @@ export type P2PMediaLoaderOptions = { export type CommonOptions = { playerElement: HTMLVideoElement + onPlayerElementChange: (element: HTMLVideoElement) => void autoplay: boolean videoDuration: number @@ -71,13 +72,14 @@ export type CommonOptions = { export type PeertubePlayerManagerOptions = { common: CommonOptions, - webtorrent?: WebtorrentOptions, + webtorrent: WebtorrentOptions, p2pMediaLoader?: P2PMediaLoaderOptions } export class PeertubePlayerManager { private static videojsLocaleCache: { [ path: string ]: any } = {} + private static playerElementClassName: string static getServerTranslations (serverUrl: string, locale: string) { const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) @@ -95,6 +97,8 @@ export class PeertubePlayerManager { static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { let p2pMediaLoader: any + this.playerElementClassName = options.common.playerElement.className + if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') if (mode === 'p2p-media-loader') { [ p2pMediaLoader ] = await Promise.all([ @@ -112,6 +116,13 @@ export class PeertubePlayerManager { videojs(options.common.playerElement, videojsOptions, function (this: any) { const player = this + player.tech_.on('error', () => { + // Fallback to webtorrent? + if (mode === 'p2p-media-loader') { + self.fallbackToWebTorrent(player, options) + } + }) + self.addContextMenu(mode, player, options.common.embedUrl) return res(player) @@ -119,6 +130,32 @@ export class PeertubePlayerManager { }) } + private static async fallbackToWebTorrent (player: any, options: PeertubePlayerManagerOptions) { + const newVideoElement = document.createElement('video') + newVideoElement.className = this.playerElementClassName + + // VideoJS wraps our video element inside a div + const currentParentPlayerElement = options.common.playerElement.parentNode + currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) + + options.common.playerElement = newVideoElement + options.common.onPlayerElementChange(newVideoElement) + + player.dispose() + + await import('./webtorrent/webtorrent-plugin') + + const mode = 'webtorrent' + const videojsOptions = this.getVideojsOptions(mode, options) + + const self = this + videojs(newVideoElement, videojsOptions, function (this: any) { + const player = this + + self.addContextMenu(mode, player, options.common.embedUrl) + }) + } + private static loadLocaleInVideoJS (serverUrl: string, locale: string) { const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) // It is the default locale, nothing to translate @@ -166,7 +203,7 @@ export class PeertubePlayerManager { } } - if (p2pMediaLoaderOptions) { + if (mode === 'p2p-media-loader') { const p2pMediaLoader: P2PMediaLoaderPluginOptions = { redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, type: 'application/x-mpegURL', @@ -209,7 +246,7 @@ export class PeertubePlayerManager { html5 = streamrootHls.html5 } - if (webtorrentOptions) { + if (mode === 'webtorrent') { const webtorrent = { autoplay, videoDuration: commonOptions.videoDuration, @@ -235,7 +272,7 @@ export class PeertubePlayerManager { : undefined, // Undefined so the player knows it has to check the local storage poster: commonOptions.poster, - autoplay, + autoplay: autoplay === true ? 'any' : autoplay, // Use 'any' instead of true to get notifier by videojs if autoplay fails inactivityTimeout: commonOptions.inactivityTimeout, playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], plugins, diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index aacbf5f6e..7ea4a06d4 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts @@ -47,7 +47,11 @@ class PeerTubePlugin extends Plugin { this.videoDuration = options.videoDuration this.videoCaptions = options.videoCaptions - if (this.autoplay === true) this.player.addClass('vjs-has-autoplay') + if (options.autoplay === true) this.player.addClass('vjs-has-autoplay') + + this.player.on('autoplay-failure', () => { + this.player.removeClass('vjs-has-autoplay') + }) this.player.ready(() => { const playerOptions = this.player.options_ -- cgit v1.2.3 From 4c280004ce62bf11ddb091854c28f1e1d54a54d6 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 7 Feb 2019 15:08:19 +0100 Subject: Use a single file instead of segments for HLS --- .../assets/player/p2p-media-loader/segment-validator.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) (limited to 'client/src/assets') diff --git a/client/src/assets/player/p2p-media-loader/segment-validator.ts b/client/src/assets/player/p2p-media-loader/segment-validator.ts index 8f4922daa..72c32f9e0 100644 --- a/client/src/assets/player/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/p2p-media-loader/segment-validator.ts @@ -3,18 +3,25 @@ import { basename } from 'path' function segmentValidatorFactory (segmentsSha256Url: string) { const segmentsJSON = fetchSha256Segments(segmentsSha256Url) + const regex = /bytes=(\d+)-(\d+)/ return async function segmentValidator (segment: Segment) { - const segmentName = basename(segment.url) + const filename = basename(segment.url) + const captured = regex.exec(segment.range) - const hashShouldBe = (await segmentsJSON)[segmentName] + const range = captured[1] + '-' + captured[2] + + const hashShouldBe = (await segmentsJSON)[filename][range] if (hashShouldBe === undefined) { - throw new Error(`Unknown segment name ${segmentName} in segment validator`) + throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) } const calculatedSha = bufferToEx(await sha256(segment.data)) if (calculatedSha !== hashShouldBe) { - throw new Error(`Hashes does not correspond for segment ${segmentName} (expected: ${hashShouldBe} instead of ${calculatedSha})`) + throw new Error( + `Hashes does not correspond for segment ${filename}/${range}` + + `(expected: ${hashShouldBe} instead of ${calculatedSha})` + ) } } } -- cgit v1.2.3