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