"@ngx-loading-bar/router": "^3.0.0",
"@ngx-meta/core": "^6.0.0-rc.1",
"@ngx-translate/i18n-polyfill": "^1.0.0",
+ "@streamroot/videojs-hlsjs-plugin": "^1.0.7",
"@types/core-js": "^2.5.0",
"@types/jasmine": "^2.8.7",
"@types/jasminewd2": "^2.0.3",
"ngx-qrcode2": "^0.0.9",
"node-sass": "^4.9.3",
"npm-font-source-sans-pro": "^1.0.2",
+ "p2p-media-loader-hlsjs": "^0.3.0",
"path-browserify": "^1.0.0",
"primeng": "^7.0.0",
"process": "^0.11.10",
"typescript": "3.1.6",
"video.js": "^7",
"videojs-contextmenu-ui": "^5.0.0",
+ "videojs-contrib-quality-levels": "^2.0.9",
"videojs-dock": "^2.0.2",
"videojs-hotkeys": "^0.2.21",
"webpack-bundle-analyzer": "^3.0.2",
import { MetaService } from '@ngx-meta/core'
import { Notifier, ServerService } from '@app/core'
import { forkJoin, Subscription } from 'rxjs'
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import videojs from 'video.js'
-import 'videojs-hotkeys'
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import * as WebTorrent from 'webtorrent'
import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
-import '../../../assets/player/peertube-videojs-plugin'
import { AuthService, ConfirmService } from '../../core'
import { RestExtractor, VideoBlacklistService } from '../../shared'
import { VideoDetails } from '../../shared/video/video-details.model'
import { VideoShareComponent } from './modal/video-share.component'
import { VideoBlacklistComponent } from './modal/video-blacklist.component'
import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
-import { addContextMenu, getVideojsOptions, loadLocaleInVideoJS } from '../../../assets/player/peertube-player'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { environment } from '../../../environments/environment'
-import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
import { VideoCaptionService } from '@app/shared/video-caption'
import { MarkdownService } from '@app/shared/renderer'
+import { PeertubePlayerManager } from '../../../assets/player/peertube-player-manager'
@Component({
selector: 'my-video-watch',
@ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
@ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
- player: videojs.Player
+ player: any
playerElement: HTMLVideoElement
userRating: UserVideoRateType = null
video: VideoDetails = null
remoteServerDown = false
hotkeys: Hotkey[]
- private videojsLocaleLoaded = false
private paramsSub: Subscription
constructor (
src: environment.apiUrl + c.captionPath
}))
- const videojsOptions = getVideojsOptions({
- autoplay: this.isAutoplay(),
- inactivityTimeout: 2500,
- videoFiles: this.video.files,
- videoCaptions: playerCaptions,
- playerElement: this.playerElement,
- videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
- videoDuration: this.video.duration,
- enableHotkeys: true,
- peertubeLink: false,
- poster: this.video.previewUrl,
- startTime,
- subtitle: urlOptions.subtitle,
- theaterMode: true,
- language: this.localeId,
-
- userWatching: this.user && this.user.videosHistoryEnabled === true ? {
- url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
- authorizationHeader: this.authService.getRequestHeaderValue()
- } : undefined
- })
+ const options = {
+ common: {
+ autoplay: this.isAutoplay(),
+ playerElement: this.playerElement,
+ videoDuration: this.video.duration,
+ enableHotkeys: true,
+ inactivityTimeout: 2500,
+ poster: this.video.previewUrl,
+ startTime,
+
+ theaterMode: true,
+ captions: videoCaptions.length !== 0,
+ peertubeLink: false,
+
+ videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
+ embedUrl: this.video.embedUrl,
+
+ language: this.localeId,
+
+ subtitle: urlOptions.subtitle,
- if (this.videojsLocaleLoaded === false) {
- await loadLocaleInVideoJS(environment.apiUrl, videojs, isOnDevLocale() ? getDevLocale() : this.localeId)
- this.videojsLocaleLoaded = true
+ userWatching: this.user && this.user.videosHistoryEnabled === true ? {
+ url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
+ authorizationHeader: this.authService.getRequestHeaderValue()
+ } : undefined,
+
+ serverUrl: environment.apiUrl,
+
+ videoCaptions: playerCaptions
+ },
+
+ webtorrent: {
+ videoFiles: this.video.files
+ }
}
- const self = this
this.zone.runOutsideAngular(async () => {
- videojs(this.playerElement, videojsOptions, function (this: videojs.Player) {
- self.player = this
- this.on('customError', ({ err }: { err: any }) => self.handleError(err))
-
- addContextMenu(self.player, self.video.embedUrl)
- })
+ this.player = await PeertubePlayerManager.initialize('webtorrent', options)
+ this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
})
this.setVideoDescriptionHTML()
--- /dev/null
+// 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 }
+++ /dev/null
-// From https://github.com/MinEduTDF/idb-chunk-store
-// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues
-// Thanks @santiagogil and @Feross
-
-import { EventEmitter } from 'events'
-import Dexie from 'dexie'
-
-class ChunkDatabase extends Dexie {
- chunks: Dexie.Table<{ id: number, buf: Buffer }, number>
-
- constructor (dbname: string) {
- super(dbname)
-
- this.version(1).stores({
- chunks: 'id'
- })
- }
-}
-
-class ExpirationDatabase extends Dexie {
- databases: Dexie.Table<{ name: string, expiration: number }, number>
-
- constructor () {
- super('webtorrent-expiration')
-
- this.version(1).stores({
- databases: 'name,expiration'
- })
- }
-}
-
-export class PeertubeChunkStore extends EventEmitter {
- private static readonly BUFFERING_PUT_MS = 1000
- private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute
- private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes
-
- chunkLength: number
-
- private pendingPut: { id: number, buf: Buffer, cb: Function }[] = []
- // If the store is full
- private memoryChunks: { [ id: number ]: Buffer | true } = {}
- private databaseName: string
- private putBulkTimeout: any
- private cleanerInterval: any
- private db: ChunkDatabase
- private expirationDB: ExpirationDatabase
- private readonly length: number
- private readonly lastChunkLength: number
- private readonly lastChunkIndex: number
-
- constructor (chunkLength: number, opts: any) {
- super()
-
- this.databaseName = 'webtorrent-chunks-'
-
- if (!opts) opts = {}
- if (opts.torrent && opts.torrent.infoHash) this.databaseName += opts.torrent.infoHash
- else this.databaseName += '-default'
-
- this.setMaxListeners(100)
-
- this.chunkLength = Number(chunkLength)
- if (!this.chunkLength) throw new Error('First argument must be a chunk length')
-
- this.length = Number(opts.length) || Infinity
-
- if (this.length !== Infinity) {
- this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength
- this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1
- }
-
- this.db = new ChunkDatabase(this.databaseName)
- // Track databases that expired
- this.expirationDB = new ExpirationDatabase()
-
- this.runCleaner()
- }
-
- put (index: number, buf: Buffer, cb: (err?: Error) => void) {
- const isLastChunk = (index === this.lastChunkIndex)
- if (isLastChunk && buf.length !== this.lastChunkLength) {
- return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength))
- }
- if (!isLastChunk && buf.length !== this.chunkLength) {
- return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength))
- }
-
- // Specify we have this chunk
- this.memoryChunks[index] = true
-
- // Add it to the pending put
- this.pendingPut.push({ id: index, buf, cb })
- // If it's already planned, return
- if (this.putBulkTimeout) return
-
- // Plan a future bulk insert
- this.putBulkTimeout = setTimeout(async () => {
- const processing = this.pendingPut
- this.pendingPut = []
- this.putBulkTimeout = undefined
-
- try {
- await this.db.transaction('rw', this.db.chunks, () => {
- return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf })))
- })
- } catch (err) {
- console.log('Cannot bulk insert chunks. Store them in memory.', { err })
-
- processing.forEach(p => this.memoryChunks[ p.id ] = p.buf)
- } finally {
- processing.forEach(p => p.cb())
- }
- }, PeertubeChunkStore.BUFFERING_PUT_MS)
- }
-
- get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void {
- if (typeof opts === 'function') return this.get(index, null, opts)
-
- // IndexDB could be slow, use our memory index first
- const memoryChunk = this.memoryChunks[index]
- if (memoryChunk === undefined) {
- const err = new Error('Chunk not found') as any
- err['notFound'] = true
-
- return process.nextTick(() => cb(err))
- }
-
- // Chunk in memory
- if (memoryChunk !== true) return cb(null, memoryChunk)
-
- // Chunk in store
- this.db.transaction('r', this.db.chunks, async () => {
- const result = await this.db.chunks.get({ id: index })
- if (result === undefined) return cb(null, new Buffer(0))
-
- const buf = result.buf
- if (!opts) return this.nextTick(cb, null, buf)
-
- const offset = opts.offset || 0
- const len = opts.length || (buf.length - offset)
- return cb(null, buf.slice(offset, len + offset))
- })
- .catch(err => {
- console.error(err)
- return cb(err)
- })
- }
-
- close (cb: (err?: Error) => void) {
- return this.destroy(cb)
- }
-
- async destroy (cb: (err?: Error) => void) {
- try {
- if (this.pendingPut) {
- clearTimeout(this.putBulkTimeout)
- this.pendingPut = null
- }
- if (this.cleanerInterval) {
- clearInterval(this.cleanerInterval)
- this.cleanerInterval = null
- }
-
- if (this.db) {
- await this.db.close()
-
- await this.dropDatabase(this.databaseName)
- }
-
- if (this.expirationDB) {
- await this.expirationDB.close()
- this.expirationDB = null
- }
-
- return cb()
- } catch (err) {
- console.error('Cannot destroy peertube chunk store.', err)
- return cb(err)
- }
- }
-
- private runCleaner () {
- this.checkExpiration()
-
- this.cleanerInterval = setInterval(async () => {
- this.checkExpiration()
- }, PeertubeChunkStore.CLEANER_INTERVAL_MS)
- }
-
- private async checkExpiration () {
- let databasesToDeleteInfo: { name: string }[] = []
-
- try {
- await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => {
- // Update our database expiration since we are alive
- await this.expirationDB.databases.put({
- name: this.databaseName,
- expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS
- })
-
- const now = new Date().getTime()
- databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray()
- })
- } catch (err) {
- console.error('Cannot update expiration of fetch expired databases.', err)
- }
-
- for (const databaseToDeleteInfo of databasesToDeleteInfo) {
- await this.dropDatabase(databaseToDeleteInfo.name)
- }
- }
-
- private async dropDatabase (databaseName: string) {
- const dbToDelete = new ChunkDatabase(databaseName)
- console.log('Destroying IndexDB database %s.', databaseName)
-
- try {
- await dbToDelete.delete()
-
- await this.expirationDB.transaction('rw', this.expirationDB.databases, () => {
- return this.expirationDB.databases.where({ name: databaseName }).delete()
- })
- } catch (err) {
- console.error('Cannot delete %s.', databaseName, err)
- }
- }
-
- private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
- process.nextTick(() => cb(err, val), undefined)
- }
-}
+++ /dev/null
-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)
+++ /dev/null
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
-
-class PeerTubeLoadProgressBar extends Component {
-
- constructor (player: Player, options: any) {
- super(player, options)
- this.partEls_ = []
- this.on(player, 'progress', this.update)
- }
-
- createEl () {
- return super.createEl('div', {
- className: 'vjs-load-progress',
- innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
- })
- }
-
- dispose () {
- this.partEls_ = null
-
- super.dispose()
- }
-
- update () {
- const torrent = this.player().peertube().getTorrent()
- if (!torrent) return
-
- this.el_.style.width = (torrent.progress * 100) + '%'
- }
-
-}
-
-Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar)
--- /dev/null
+import { VideoFile } from '../../../../shared/models/videos'
+// @ts-ignore
+import * as videojs from 'video.js'
+import 'videojs-hotkeys'
+import 'videojs-dock'
+import 'videojs-contextmenu-ui'
+import 'videojs-contrib-quality-levels'
+import './peertube-plugin'
+import './videojs-components/peertube-link-button'
+import './videojs-components/resolution-menu-button'
+import './videojs-components/settings-menu-button'
+import './videojs-components/p2p-info-button'
+import './videojs-components/peertube-load-progress-bar'
+import './videojs-components/theater-button'
+import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings'
+import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
+import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
+import { Engine } from 'p2p-media-loader-hlsjs'
+
+// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
+videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
+// Change Captions to Subtitles/CC
+videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC'
+// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
+videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
+
+type PlayerMode = 'webtorrent' | 'p2p-media-loader'
+
+type WebtorrentOptions = {
+ videoFiles: VideoFile[]
+}
+
+type P2PMediaLoaderOptions = {
+ playlistUrl: string
+}
+
+type CommonOptions = {
+ playerElement: HTMLVideoElement
+
+ autoplay: boolean
+ videoDuration: number
+ enableHotkeys: boolean
+ inactivityTimeout: number
+ poster: string
+ startTime: number | string
+
+ theaterMode: boolean
+ captions: boolean
+ peertubeLink: boolean
+
+ videoViewUrl: string
+ embedUrl: string
+
+ language?: string
+ controls?: boolean
+ muted?: boolean
+ loop?: boolean
+ subtitle?: string
+
+ videoCaptions: VideoJSCaption[]
+
+ userWatching?: UserWatching
+
+ serverUrl: string
+}
+
+export type PeertubePlayerManagerOptions = {
+ common: CommonOptions,
+ webtorrent?: WebtorrentOptions,
+ p2pMediaLoader?: P2PMediaLoaderOptions
+}
+
+export class PeertubePlayerManager {
+
+ private static videojsLocaleCache: { [ path: string ]: any } = {}
+
+ static getServerTranslations (serverUrl: string, locale: string) {
+ const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
+ // It is the default locale, nothing to translate
+ if (!path) return Promise.resolve(undefined)
+
+ return fetch(path + '/server.json')
+ .then(res => res.json())
+ .catch(err => {
+ console.error('Cannot get server translations', err)
+ return undefined
+ })
+ }
+
+ static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
+ if (mode === 'webtorrent') await import('./webtorrent-plugin')
+ if (mode === 'p2p-media-loader') await import('./p2p-media-loader-plugin')
+
+ const videojsOptions = this.getVideojsOptions(mode, options)
+
+ await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language)
+
+ const self = this
+ return new Promise(res => {
+ videojs(options.common.playerElement, videojsOptions, function (this: any) {
+ const player = this
+
+ self.addContextMenu(mode, player, options.common.embedUrl)
+
+ return res(player)
+ })
+ })
+ }
+
+ private static loadLocaleInVideoJS (serverUrl: string, locale: string) {
+ const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
+ // It is the default locale, nothing to translate
+ if (!path) return Promise.resolve(undefined)
+
+ let p: Promise<any>
+
+ if (PeertubePlayerManager.videojsLocaleCache[path]) {
+ p = Promise.resolve(PeertubePlayerManager.videojsLocaleCache[path])
+ } else {
+ p = fetch(path + '/player.json')
+ .then(res => res.json())
+ .then(json => {
+ PeertubePlayerManager.videojsLocaleCache[path] = json
+ return json
+ })
+ .catch(err => {
+ console.error('Cannot get player translations', err)
+ return undefined
+ })
+ }
+
+ const completeLocale = getCompleteLocale(locale)
+ return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
+ }
+
+ private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
+ const commonOptions = options.common
+ const webtorrentOptions = options.webtorrent
+ const p2pMediaLoaderOptions = options.p2pMediaLoader
+
+ const plugins: VideoJSPluginOptions = {
+ peertube: {
+ autoplay: commonOptions.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
+ videoViewUrl: commonOptions.videoViewUrl,
+ videoDuration: commonOptions.videoDuration,
+ startTime: commonOptions.startTime,
+ userWatching: commonOptions.userWatching,
+ subtitle: commonOptions.subtitle,
+ videoCaptions: commonOptions.videoCaptions
+ }
+ }
+
+ if (p2pMediaLoaderOptions) {
+ const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
+ type: 'application/x-mpegURL',
+ src: p2pMediaLoaderOptions.playlistUrl
+ }
+
+ const config = {
+ segments: {
+ swarmId: 'swarm' // TODO: choose swarm id
+ }
+ }
+ const streamrootHls = {
+ html5: {
+ hlsjsConfig: {
+ liveSyncDurationCount: 7,
+ loader: new Engine(config).createLoaderClass()
+ }
+ }
+ }
+
+ Object.assign(plugins, { p2pMediaLoader, streamrootHls })
+ }
+
+ if (webtorrentOptions) {
+ const webtorrent = {
+ autoplay: commonOptions.autoplay,
+ videoDuration: commonOptions.videoDuration,
+ playerElement: commonOptions.playerElement,
+ videoFiles: webtorrentOptions.videoFiles
+ }
+ Object.assign(plugins, { webtorrent })
+ }
+
+ const videojsOptions = {
+ // We don't use text track settings for now
+ textTrackSettings: false,
+ controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
+ loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
+
+ muted: commonOptions.muted !== undefined
+ ? commonOptions.muted
+ : undefined, // Undefined so the player knows it has to check the local storage
+
+ poster: commonOptions.poster,
+ autoplay: false,
+ inactivityTimeout: commonOptions.inactivityTimeout,
+ playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
+ plugins,
+ controlBar: {
+ children: this.getControlBarChildren(mode, {
+ captions: commonOptions.captions,
+ peertubeLink: commonOptions.peertubeLink,
+ theaterMode: commonOptions.theaterMode
+ })
+ }
+ }
+
+ if (commonOptions.enableHotkeys === true) {
+ Object.assign(videojsOptions.plugins, {
+ hotkeys: {
+ enableVolumeScroll: false,
+ enableModifiersForNumbers: false,
+
+ fullscreenKey: function (event: KeyboardEvent) {
+ // fullscreen with the f key or Ctrl+Enter
+ return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
+ },
+
+ seekStep: function (event: KeyboardEvent) {
+ // mimic VLC seek behavior, and default to 5 (original value is 5).
+ if (event.ctrlKey && event.altKey) {
+ return 5 * 60
+ } else if (event.ctrlKey) {
+ return 60
+ } else if (event.altKey) {
+ return 10
+ } else {
+ return 5
+ }
+ },
+
+ customKeys: {
+ increasePlaybackRateKey: {
+ key: function (event: KeyboardEvent) {
+ return event.key === '>'
+ },
+ handler: function (player: videojs.Player) {
+ player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
+ }
+ },
+ decreasePlaybackRateKey: {
+ key: function (event: KeyboardEvent) {
+ return event.key === '<'
+ },
+ handler: function (player: videojs.Player) {
+ player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
+ }
+ },
+ frameByFrame: {
+ key: function (event: KeyboardEvent) {
+ return event.key === '.'
+ },
+ handler: function (player: videojs.Player) {
+ player.pause()
+ // Calculate movement distance (assuming 30 fps)
+ const dist = 1 / 30
+ player.currentTime(player.currentTime() + dist)
+ }
+ }
+ }
+ }
+ })
+ }
+
+ if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
+ Object.assign(videojsOptions, { language: commonOptions.language })
+ }
+
+ return videojsOptions
+ }
+
+ private static getControlBarChildren (mode: PlayerMode, options: {
+ peertubeLink: boolean
+ theaterMode: boolean,
+ captions: boolean
+ }) {
+ const settingEntries = []
+ const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
+
+ // Keep an order
+ settingEntries.push('playbackRateMenuButton')
+ if (options.captions === true) settingEntries.push('captionsButton')
+ settingEntries.push('resolutionMenuButton')
+
+ const children = {
+ 'playToggle': {},
+ 'currentTimeDisplay': {},
+ 'timeDivider': {},
+ 'durationDisplay': {},
+ 'liveDisplay': {},
+
+ 'flexibleWidthSpacer': {},
+ 'progressControl': {
+ children: {
+ 'seekBar': {
+ children: {
+ [loadProgressBar]: {},
+ 'mouseTimeDisplay': {},
+ 'playProgressBar': {}
+ }
+ }
+ }
+ },
+
+ 'p2PInfoButton': {},
+
+ 'muteToggle': {},
+ 'volumeControl': {},
+
+ 'settingsButton': {
+ setup: {
+ maxHeightOffset: 40
+ },
+ entries: settingEntries
+ }
+ }
+
+ if (options.peertubeLink === true) {
+ Object.assign(children, {
+ 'peerTubeLinkButton': {}
+ })
+ }
+
+ if (options.theaterMode === true) {
+ Object.assign(children, {
+ 'theaterButton': {}
+ })
+ }
+
+ Object.assign(children, {
+ 'fullscreenToggle': {}
+ })
+
+ return children
+ }
+
+ private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) {
+ const content = [
+ {
+ label: player.localize('Copy the video URL'),
+ listener: function () {
+ copyToClipboard(buildVideoLink())
+ }
+ },
+ {
+ label: player.localize('Copy the video URL at the current time'),
+ listener: function () {
+ const player = this as videojs.Player
+ copyToClipboard(buildVideoLink(player.currentTime()))
+ }
+ },
+ {
+ label: player.localize('Copy embed code'),
+ listener: () => {
+ copyToClipboard(buildVideoEmbed(videoEmbedUrl))
+ }
+ }
+ ]
+
+ if (mode === 'webtorrent') {
+ content.push({
+ label: player.localize('Copy magnet URI'),
+ listener: function () {
+ const player = this as videojs.Player
+ copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri)
+ }
+ })
+ }
+
+ player.contextmenuUI({ content })
+ }
+
+ private static getLocalePath (serverUrl: string, locale: string) {
+ const completeLocale = getCompleteLocale(locale)
+
+ if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
+
+ return serverUrl + '/client/locales/' + completeLocale
+ }
+}
+
+// ############################################################################
+
+export {
+ videojs
+}
+++ /dev/null
-import { VideoFile } from '../../../../shared/models/videos'
-
-import 'videojs-hotkeys'
-import 'videojs-dock'
-import 'videojs-contextmenu-ui'
-import './peertube-link-button'
-import './resolution-menu-button'
-import './settings-menu-button'
-import './webtorrent-info-button'
-import './peertube-videojs-plugin'
-import './peertube-load-progress-bar'
-import './theater-button'
-import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
-import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
-import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
-
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
-videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
-// Change Captions to Subtitles/CC
-videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC'
-// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
-videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
-
-function getVideojsOptions (options: {
- autoplay: boolean
- playerElement: HTMLVideoElement
- videoViewUrl: string
- videoDuration: number
- videoFiles: VideoFile[]
- enableHotkeys: boolean
- inactivityTimeout: number
- peertubeLink: boolean
- poster: string
- startTime: number | string
- theaterMode: boolean
- videoCaptions: VideoJSCaption[]
-
- language?: string
- controls?: boolean
- muted?: boolean
- loop?: boolean
- subtitle?: string
-
- userWatching?: UserWatching
-}) {
- const videojsOptions = {
- // We don't use text track settings for now
- textTrackSettings: false,
- controls: options.controls !== undefined ? options.controls : true,
- loop: options.loop !== undefined ? options.loop : false,
-
- muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage
-
- poster: options.poster,
- autoplay: false,
- inactivityTimeout: options.inactivityTimeout,
- playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
- plugins: {
- peertube: {
- autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
- videoCaptions: options.videoCaptions,
- videoFiles: options.videoFiles,
- playerElement: options.playerElement,
- videoViewUrl: options.videoViewUrl,
- videoDuration: options.videoDuration,
- startTime: options.startTime,
- userWatching: options.userWatching,
- subtitle: options.subtitle
- }
- },
- controlBar: {
- children: getControlBarChildren(options)
- }
- }
-
- if (options.enableHotkeys === true) {
- Object.assign(videojsOptions.plugins, {
- hotkeys: {
- enableVolumeScroll: false,
- enableModifiersForNumbers: false,
-
- fullscreenKey: function (event: KeyboardEvent) {
- // fullscreen with the f key or Ctrl+Enter
- return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
- },
-
- seekStep: function (event: KeyboardEvent) {
- // mimic VLC seek behavior, and default to 5 (original value is 5).
- if (event.ctrlKey && event.altKey) {
- return 5 * 60
- } else if (event.ctrlKey) {
- return 60
- } else if (event.altKey) {
- return 10
- } else {
- return 5
- }
- },
-
- customKeys: {
- increasePlaybackRateKey: {
- key: function (event: KeyboardEvent) {
- return event.key === '>'
- },
- handler: function (player: Player) {
- player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
- }
- },
- decreasePlaybackRateKey: {
- key: function (event: KeyboardEvent) {
- return event.key === '<'
- },
- handler: function (player: Player) {
- player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
- }
- },
- frameByFrame: {
- key: function (event: KeyboardEvent) {
- return event.key === '.'
- },
- handler: function (player: Player) {
- player.pause()
- // Calculate movement distance (assuming 30 fps)
- const dist = 1 / 30
- player.currentTime(player.currentTime() + dist)
- }
- }
- }
- }
- })
- }
-
- if (options.language && !isDefaultLocale(options.language)) {
- Object.assign(videojsOptions, { language: options.language })
- }
-
- return videojsOptions
-}
-
-function getControlBarChildren (options: {
- peertubeLink: boolean
- theaterMode: boolean,
- videoCaptions: VideoJSCaption[]
-}) {
- const settingEntries = []
-
- // Keep an order
- settingEntries.push('playbackRateMenuButton')
- if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton')
- settingEntries.push('resolutionMenuButton')
-
- const children = {
- 'playToggle': {},
- 'currentTimeDisplay': {},
- 'timeDivider': {},
- 'durationDisplay': {},
- 'liveDisplay': {},
-
- 'flexibleWidthSpacer': {},
- 'progressControl': {
- children: {
- 'seekBar': {
- children: {
- 'peerTubeLoadProgressBar': {},
- 'mouseTimeDisplay': {},
- 'playProgressBar': {}
- }
- }
- }
- },
-
- 'webTorrentButton': {},
-
- 'muteToggle': {},
- 'volumeControl': {},
-
- 'settingsButton': {
- setup: {
- maxHeightOffset: 40
- },
- entries: settingEntries
- }
- }
-
- if (options.peertubeLink === true) {
- Object.assign(children, {
- 'peerTubeLinkButton': {}
- })
- }
-
- if (options.theaterMode === true) {
- Object.assign(children, {
- 'theaterButton': {}
- })
- }
-
- Object.assign(children, {
- 'fullscreenToggle': {}
- })
-
- return children
-}
-
-function addContextMenu (player: any, videoEmbedUrl: string) {
- player.contextmenuUI({
- content: [
- {
- label: player.localize('Copy the video URL'),
- listener: function () {
- copyToClipboard(buildVideoLink())
- }
- },
- {
- label: player.localize('Copy the video URL at the current time'),
- listener: function () {
- const player = this as Player
- copyToClipboard(buildVideoLink(player.currentTime()))
- }
- },
- {
- label: player.localize('Copy embed code'),
- listener: () => {
- copyToClipboard(buildVideoEmbed(videoEmbedUrl))
- }
- },
- {
- label: player.localize('Copy magnet URI'),
- listener: function () {
- const player = this as Player
- copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri)
- }
- }
- ]
- })
-}
-
-function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) {
- const path = getLocalePath(serverUrl, locale)
- // It is the default locale, nothing to translate
- if (!path) return Promise.resolve(undefined)
-
- let p: Promise<any>
-
- if (loadLocaleInVideoJS.cache[path]) {
- p = Promise.resolve(loadLocaleInVideoJS.cache[path])
- } else {
- p = fetch(path + '/player.json')
- .then(res => res.json())
- .then(json => {
- loadLocaleInVideoJS.cache[path] = json
- return json
- })
- .catch(err => {
- console.error('Cannot get player translations', err)
- return undefined
- })
- }
-
- const completeLocale = getCompleteLocale(locale)
- return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
-}
-namespace loadLocaleInVideoJS {
- export const cache: { [ path: string ]: any } = {}
-}
-
-function getServerTranslations (serverUrl: string, locale: string) {
- const path = getLocalePath(serverUrl, locale)
- // It is the default locale, nothing to translate
- if (!path) return Promise.resolve(undefined)
-
- return fetch(path + '/server.json')
- .then(res => res.json())
- .catch(err => {
- console.error('Cannot get server translations', err)
- return undefined
- })
-}
-
-// ############################################################################
-
-export {
- getServerTranslations,
- loadLocaleInVideoJS,
- getVideojsOptions,
- addContextMenu
-}
-
-// ############################################################################
-
-function getLocalePath (serverUrl: string, locale: string) {
- const completeLocale = getCompleteLocale(locale)
-
- if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
-
- return serverUrl + '/client/locales/' + completeLocale
-}
--- /dev/null
+// 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 }
+++ /dev/null
-// 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 }
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
}
}
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
}
+++ /dev/null
-// 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)
+++ /dev/null
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import { Player } from 'video.js'
-
-import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
-
-const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
-class ResolutionMenuItem extends MenuItem {
-
- constructor (player: Player, options: any) {
- const currentResolutionId = player.peertube().getCurrentResolutionId()
- options.selectable = true
- options.selected = options.id === currentResolutionId
-
- super(player, options)
-
- this.label = options.label
- this.id = options.id
-
- player.peertube().on('videoFileUpdate', () => this.updateSelection())
- player.peertube().on('autoResolutionUpdate', () => this.updateSelection())
- }
-
- handleClick (event: any) {
- if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return
-
- super.handleClick(event)
-
- // Auto resolution
- if (this.id === -1) {
- this.player_.peertube().enableAutoResolution()
- return
- }
-
- this.player_.peertube().disableAutoResolution()
- this.player_.peertube().updateResolution(this.id)
- }
-
- updateSelection () {
- // Check if auto resolution is forbidden or not
- if (this.id === -1) {
- if (this.player_.peertube().isAutoResolutionForbidden()) {
- this.addClass('disabled')
- } else {
- this.removeClass('disabled')
- }
- }
-
- if (this.player_.peertube().isAutoResolutionOn()) {
- this.selected(this.id === -1)
- return
- }
-
- this.selected(this.player_.peertube().getCurrentResolutionId() === this.id)
- }
-
- getLabel () {
- if (this.id === -1) {
- return this.label + ' <small>' + this.player_.peertube().getCurrentResolutionLabel() + '</small>'
- }
-
- return this.label
- }
-}
-MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
-
-export { ResolutionMenuItem }
+++ /dev/null
-// 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 }
+++ /dev/null
-// 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 }
+++ /dev/null
-// 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)
+++ /dev/null
-// 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
-}
--- /dev/null
+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)
--- /dev/null
+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)
--- /dev/null
+import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import { Player } from 'video.js'
+
+const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
+
+class PeerTubeLoadProgressBar extends Component {
+
+ constructor (player: Player, options: any) {
+ super(player, options)
+ this.partEls_ = []
+ this.on(player, 'progress', this.update)
+ }
+
+ createEl () {
+ return super.createEl('div', {
+ className: 'vjs-load-progress',
+ innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
+ })
+ }
+
+ dispose () {
+ this.partEls_ = null
+
+ super.dispose()
+ }
+
+ update () {
+ const torrent = this.player().webtorrent().getTorrent()
+ if (!torrent) return
+
+ this.el_.style.width = (torrent.progress * 100) + '%'
+ }
+
+}
+
+Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar)
--- /dev/null
+// 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)
--- /dev/null
+// FIXME: something weird with our path definition in tsconfig and typings
+// @ts-ignore
+import { Player } from 'video.js'
+
+import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
+
+const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
+class ResolutionMenuItem extends MenuItem {
+ private readonly id: number
+ private readonly label: string
+ // Only used for the automatic item
+ private readonly labels: { [id: number]: string }
+ private readonly callback: Function
+
+ private autoResolutionPossible: boolean
+ private currentResolutionLabel: string
+
+ constructor (player: Player, options: any) {
+ options.selectable = true
+
+ super(player, options)
+
+ this.autoResolutionPossible = true
+ this.currentResolutionLabel = ''
+
+ this.label = options.label
+ this.labels = options.labels
+ this.id = options.id
+ this.callback = options.callback
+
+ if (player.webtorrent) {
+ player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
+
+ // We only want to disable the "Auto" item
+ if (this.id === -1) {
+ player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
+ }
+ }
+
+ // TODO: update on HLS change
+ }
+
+ handleClick (event: any) {
+ // Auto button disabled?
+ if (this.autoResolutionPossible === false && this.id === -1) return
+
+ super.handleClick(event)
+
+ this.callback(this.id)
+ }
+
+ updateSelection (data: ResolutionUpdateData) {
+ if (this.id === -1) {
+ this.currentResolutionLabel = this.labels[data.resolutionId]
+ }
+
+ // Automatic resolution only
+ if (data.auto === true) {
+ this.selected(this.id === -1)
+ return
+ }
+
+ this.selected(this.id === data.resolutionId)
+ }
+
+ updateAutoResolution (data: AutoResolutionUpdateData) {
+ // Check if the auto resolution is enabled or not
+ if (data.possible === false) {
+ this.addClass('disabled')
+ } else {
+ this.removeClass('disabled')
+ }
+
+ this.autoResolutionPossible = data.possible
+ }
+
+ getLabel () {
+ if (this.id === -1) {
+ return this.label + ' <small>' + this.currentResolutionLabel + '</small>'
+ }
+
+ return this.label
+ }
+}
+MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
+
+export { ResolutionMenuItem }
--- /dev/null
+// 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 }
--- /dev/null
+// 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 }
--- /dev/null
+// 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)
+++ /dev/null
-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)
--- /dev/null
+// 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 }
--- /dev/null
+// From https://github.com/MinEduTDF/idb-chunk-store
+// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues
+// Thanks @santiagogil and @Feross
+
+import { EventEmitter } from 'events'
+import Dexie from 'dexie'
+
+class ChunkDatabase extends Dexie {
+ chunks: Dexie.Table<{ id: number, buf: Buffer }, number>
+
+ constructor (dbname: string) {
+ super(dbname)
+
+ this.version(1).stores({
+ chunks: 'id'
+ })
+ }
+}
+
+class ExpirationDatabase extends Dexie {
+ databases: Dexie.Table<{ name: string, expiration: number }, number>
+
+ constructor () {
+ super('webtorrent-expiration')
+
+ this.version(1).stores({
+ databases: 'name,expiration'
+ })
+ }
+}
+
+export class PeertubeChunkStore extends EventEmitter {
+ private static readonly BUFFERING_PUT_MS = 1000
+ private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute
+ private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes
+
+ chunkLength: number
+
+ private pendingPut: { id: number, buf: Buffer, cb: Function }[] = []
+ // If the store is full
+ private memoryChunks: { [ id: number ]: Buffer | true } = {}
+ private databaseName: string
+ private putBulkTimeout: any
+ private cleanerInterval: any
+ private db: ChunkDatabase
+ private expirationDB: ExpirationDatabase
+ private readonly length: number
+ private readonly lastChunkLength: number
+ private readonly lastChunkIndex: number
+
+ constructor (chunkLength: number, opts: any) {
+ super()
+
+ this.databaseName = 'webtorrent-chunks-'
+
+ if (!opts) opts = {}
+ if (opts.torrent && opts.torrent.infoHash) this.databaseName += opts.torrent.infoHash
+ else this.databaseName += '-default'
+
+ this.setMaxListeners(100)
+
+ this.chunkLength = Number(chunkLength)
+ if (!this.chunkLength) throw new Error('First argument must be a chunk length')
+
+ this.length = Number(opts.length) || Infinity
+
+ if (this.length !== Infinity) {
+ this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength
+ this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1
+ }
+
+ this.db = new ChunkDatabase(this.databaseName)
+ // Track databases that expired
+ this.expirationDB = new ExpirationDatabase()
+
+ this.runCleaner()
+ }
+
+ put (index: number, buf: Buffer, cb: (err?: Error) => void) {
+ const isLastChunk = (index === this.lastChunkIndex)
+ if (isLastChunk && buf.length !== this.lastChunkLength) {
+ return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength))
+ }
+ if (!isLastChunk && buf.length !== this.chunkLength) {
+ return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength))
+ }
+
+ // Specify we have this chunk
+ this.memoryChunks[index] = true
+
+ // Add it to the pending put
+ this.pendingPut.push({ id: index, buf, cb })
+ // If it's already planned, return
+ if (this.putBulkTimeout) return
+
+ // Plan a future bulk insert
+ this.putBulkTimeout = setTimeout(async () => {
+ const processing = this.pendingPut
+ this.pendingPut = []
+ this.putBulkTimeout = undefined
+
+ try {
+ await this.db.transaction('rw', this.db.chunks, () => {
+ return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf })))
+ })
+ } catch (err) {
+ console.log('Cannot bulk insert chunks. Store them in memory.', { err })
+
+ processing.forEach(p => this.memoryChunks[ p.id ] = p.buf)
+ } finally {
+ processing.forEach(p => p.cb())
+ }
+ }, PeertubeChunkStore.BUFFERING_PUT_MS)
+ }
+
+ get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void {
+ if (typeof opts === 'function') return this.get(index, null, opts)
+
+ // IndexDB could be slow, use our memory index first
+ const memoryChunk = this.memoryChunks[index]
+ if (memoryChunk === undefined) {
+ const err = new Error('Chunk not found') as any
+ err['notFound'] = true
+
+ return process.nextTick(() => cb(err))
+ }
+
+ // Chunk in memory
+ if (memoryChunk !== true) return cb(null, memoryChunk)
+
+ // Chunk in store
+ this.db.transaction('r', this.db.chunks, async () => {
+ const result = await this.db.chunks.get({ id: index })
+ if (result === undefined) return cb(null, new Buffer(0))
+
+ const buf = result.buf
+ if (!opts) return this.nextTick(cb, null, buf)
+
+ const offset = opts.offset || 0
+ const len = opts.length || (buf.length - offset)
+ return cb(null, buf.slice(offset, len + offset))
+ })
+ .catch(err => {
+ console.error(err)
+ return cb(err)
+ })
+ }
+
+ close (cb: (err?: Error) => void) {
+ return this.destroy(cb)
+ }
+
+ async destroy (cb: (err?: Error) => void) {
+ try {
+ if (this.pendingPut) {
+ clearTimeout(this.putBulkTimeout)
+ this.pendingPut = null
+ }
+ if (this.cleanerInterval) {
+ clearInterval(this.cleanerInterval)
+ this.cleanerInterval = null
+ }
+
+ if (this.db) {
+ await this.db.close()
+
+ await this.dropDatabase(this.databaseName)
+ }
+
+ if (this.expirationDB) {
+ await this.expirationDB.close()
+ this.expirationDB = null
+ }
+
+ return cb()
+ } catch (err) {
+ console.error('Cannot destroy peertube chunk store.', err)
+ return cb(err)
+ }
+ }
+
+ private runCleaner () {
+ this.checkExpiration()
+
+ this.cleanerInterval = setInterval(async () => {
+ this.checkExpiration()
+ }, PeertubeChunkStore.CLEANER_INTERVAL_MS)
+ }
+
+ private async checkExpiration () {
+ let databasesToDeleteInfo: { name: string }[] = []
+
+ try {
+ await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => {
+ // Update our database expiration since we are alive
+ await this.expirationDB.databases.put({
+ name: this.databaseName,
+ expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS
+ })
+
+ const now = new Date().getTime()
+ databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray()
+ })
+ } catch (err) {
+ console.error('Cannot update expiration of fetch expired databases.', err)
+ }
+
+ for (const databaseToDeleteInfo of databasesToDeleteInfo) {
+ await this.dropDatabase(databaseToDeleteInfo.name)
+ }
+ }
+
+ private async dropDatabase (databaseName: string) {
+ const dbToDelete = new ChunkDatabase(databaseName)
+ console.log('Destroying IndexDB database %s.', databaseName)
+
+ try {
+ await dbToDelete.delete()
+
+ await this.expirationDB.transaction('rw', this.expirationDB.databases, () => {
+ return this.expirationDB.databases.where({ name: databaseName }).delete()
+ })
+ } catch (err) {
+ console.error('Cannot delete %s.', databaseName, err)
+ }
+ }
+
+ private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
+ process.nextTick(() => cb(err, val), undefined)
+ }
+}
--- /dev/null
+// 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
+}
// For google bot that uses Chrome 41 and does not understand fetch
import 'whatwg-fetch'
-// FIXME: something weird with our path definition in tsconfig and typings
-// @ts-ignore
-import * as vjs from 'video.js'
-
import * as Channel from 'jschannel'
import { peertubeTranslate, ResultList, VideoDetails } from '../../../../shared'
-import { addContextMenu, getServerTranslations, getVideojsOptions, loadLocaleInVideoJS } from '../../assets/player/peertube-player'
import { PeerTubeResolution } from '../player/definitions'
import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
+import { PeertubePlayerManager, PeertubePlayerManagerOptions } from '../../assets/player/peertube-player-manager'
/**
* Embed API exposes control of the embed player to the outside world via
}
private setResolution (resolutionId: number) {
- if (resolutionId === -1 && this.embed.player.peertube().isAutoResolutionForbidden()) return
+ if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionForbidden()) return
// Auto resolution
if (resolutionId === -1) {
- this.embed.player.peertube().enableAutoResolution()
+ this.embed.player.webtorrent().enableAutoResolution()
return
}
- this.embed.player.peertube().disableAutoResolution()
- this.embed.player.peertube().updateResolution(resolutionId)
+ this.embed.player.webtorrent().disableAutoResolution()
+ this.embed.player.webtorrent().updateResolution(resolutionId)
}
/**
// PeerTube specific capabilities
- this.embed.player.peertube().on('autoResolutionUpdate', () => this.loadResolutions())
- this.embed.player.peertube().on('videoFileUpdate', () => this.loadResolutions())
+ if (this.embed.player.webtorrent) {
+ this.embed.player.webtorrent().on('autoResolutionUpdate', () => this.loadWebTorrentResolutions())
+ this.embed.player.webtorrent().on('videoFileUpdate', () => this.loadWebTorrentResolutions())
+ }
}
- private loadResolutions () {
+ private loadWebTorrentResolutions () {
let resolutions = []
- let currentResolutionId = this.embed.player.peertube().getCurrentResolutionId()
+ let currentResolutionId = this.embed.player.webtorrent().getCurrentResolutionId()
- for (const videoFile of this.embed.player.peertube().videoFiles) {
+ for (const videoFile of this.embed.player.webtorrent().videoFiles) {
let label = videoFile.resolution.label
if (videoFile.fps && videoFile.fps >= 50) {
label += videoFile.fps
const urlParts = window.location.pathname.split('/')
const videoId = urlParts[ urlParts.length - 1 ]
- const [ , serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
- loadLocaleInVideoJS(window.location.origin, vjs, navigator.language),
- getServerTranslations(window.location.origin, navigator.language),
+ const [ serverTranslations, videoResponse, captionsResponse ] = await Promise.all([
+ PeertubePlayerManager.getServerTranslations(window.location.origin, navigator.language),
this.loadVideoInfo(videoId),
this.loadVideoCaptions(videoId)
])
this.loadParams()
- const videojsOptions = getVideojsOptions({
- autoplay: this.autoplay,
- controls: this.controls,
- muted: this.muted,
- loop: this.loop,
- startTime: this.startTime,
- subtitle: this.subtitle,
-
- videoCaptions,
- inactivityTimeout: 1500,
- videoViewUrl: this.getVideoUrl(videoId) + '/views',
- playerElement: this.videoElement,
- videoFiles: videoInfo.files,
- videoDuration: videoInfo.duration,
- enableHotkeys: true,
- peertubeLink: true,
- poster: window.location.origin + videoInfo.previewPath,
- theaterMode: false
- })
+ const options: PeertubePlayerManagerOptions = {
+ common: {
+ autoplay: this.autoplay,
+ controls: this.controls,
+ muted: this.muted,
+ loop: this.loop,
+ captions: videoCaptions.length !== 0,
+ startTime: this.startTime,
+ subtitle: this.subtitle,
+
+ videoCaptions,
+ inactivityTimeout: 1500,
+ videoViewUrl: this.getVideoUrl(videoId) + '/views',
+ playerElement: this.videoElement,
+ videoDuration: videoInfo.duration,
+ enableHotkeys: true,
+ peertubeLink: true,
+ poster: window.location.origin + videoInfo.previewPath,
+ theaterMode: false,
+
+ serverUrl: window.location.origin,
+ language: navigator.language,
+ embedUrl: window.location.origin + videoInfo.embedPath
+ },
+
+ webtorrent: {
+ videoFiles: videoInfo.files
+ }
+
+ // p2pMediaLoader: {
+ // // playlistUrl: 'https://akamai-axtest.akamaized.net/routes/lapd-v1-acceptance/www_c4/Manifest.m3u8'
+ // // playlistUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8'
+ // playlistUrl: 'https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'
+ // }
+ }
- this.playerOptions = videojsOptions
- this.player = vjs(this.videoContainerId, videojsOptions, () => {
- this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
+ this.player = await PeertubePlayerManager.initialize('webtorrent', options)
- window[ 'videojsPlayer' ] = this.player
+ this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
- if (this.controls) {
- this.player.dock({
- title: videoInfo.name,
- description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
- })
- }
+ window[ 'videojsPlayer' ] = this.player
- addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
+ if (this.controls) {
+ this.player.dock({
+ title: videoInfo.name,
+ description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
+ })
+ }
- this.initializeApi()
- })
+ this.initializeApi()
}
private handleError (err: Error, translations?: { [ id: string ]: string }) {
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
- "module": "es2015",
+ "module": "esnext",
"types": [],
"lib": [
"es2017",
semver "5.5.1"
semver-intersect "1.4.0"
+"@streamroot/videojs-hlsjs-plugin@^1.0.7":
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/@streamroot/videojs-hlsjs-plugin/-/videojs-hlsjs-plugin-1.0.7.tgz#581aecdf6a966162b404c60bd3ab8264eb89d334"
+ integrity sha512-7oAIOhEFxkfLOYWDfg7Oh3+OrnoTElRvUE3Jblg2B+SHmnrw4YXQnAwYJ0AHjNIBKoHnQubzZGttLaHAFJVspQ==
+
"@types/bittorrent-protocol@*":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@types/bittorrent-protocol/-/bittorrent-protocol-2.2.2.tgz#169e9633e1bd18e6b830d11cf42e611b1972cb83"
unordered-array-remove "^1.0.2"
xtend "^4.0.0"
-bittorrent-tracker@^9.0.0:
+bittorrent-tracker@^9.0.0, bittorrent-tracker@^9.10.1:
version "9.10.1"
resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.10.1.tgz#5de14aac012a287af394d3cc9eda1ec6cc956f11"
integrity sha512-n5zTL/g6Wt0rb2EnkiyiaGYhth7I/N0/xMqGUpvGX/7g1scDGBVPhJnXR8lfp3/OMj681fv40o4q/otECMtZSA==
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=
+events@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
+ integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==
+
eventsource@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0"
resolved "https://registry.yarnpkg.com/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537"
integrity sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA==
-get-browser-rtc@^1.0.0:
+get-browser-rtc@^1.0.0, get-browser-rtc@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz#bbcd40c8451a7ed4ef5c373b8169a409dd1d11d9"
integrity sha1-u81AyEUaftTvXDc7gWmkCd0dEdk=
resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.2.0.tgz#c8e0785fd17f741f4408b49466889274a9e36447"
integrity sha512-LVHw0U6IPJjwk9i9f7Xe26NqaUHTNlIt4SSWoEfYFROeVKHN6MIjOhbRheI3dg8Jbq5WCuMFQ0QU3EgZpmzFPg==
+m3u8-parser@^4.2.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.3.0.tgz#4b4e988f87b6d8b2401d209a1d17798285a9da04"
+ integrity sha512-bVbjuBMoVIgFL1vpXVIxjeaoB5TPDJRb0m5qiTdM738SGqv/LAmsnVVPlKjM4fulm/rr1XZsKM+owHm+zvqxYA==
+ dependencies:
+ global "^4.3.2"
+
magic-string@^0.25.0:
version "0.25.1"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.1.tgz#b1c248b399cd7485da0fe7385c2fc7011843266e"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==
+p2p-media-loader-core@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/p2p-media-loader-core/-/p2p-media-loader-core-0.3.0.tgz#75687d7d7bee835d5c6c2f17d346add2dbe43b83"
+ integrity sha512-WKB9ONdA0kDQHXr6nixIL8t0UZuTD9Pqi/BIuaTiPUGDwYXUS/Mf5YynLAUupniLkIaDYD7/jmSLWqpZUDsAyw==
+ dependencies:
+ bittorrent-tracker "^9.10.1"
+ debug "^4.1.0"
+ events "^3.0.0"
+ get-browser-rtc "^1.0.2"
+ sha.js "^2.4.11"
+
+p2p-media-loader-hlsjs@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-0.3.0.tgz#4ee15d4d1a23aa0322a5be2bc6c329b6c913028d"
+ integrity sha512-U7PzMG5X7CVQ15OtMPRQjW68Msu0fuw8Pp0PRznX5uK0p26tSYMT/ZYCNeYCoDg3wGgJHM+327ed3M7TRJ4lcw==
+ dependencies:
+ events "^3.0.0"
+ m3u8-parser "^4.2.0"
+ p2p-media-loader-core "^0.3.0"
+
package-json-versionify@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/package-json-versionify/-/package-json-versionify-1.0.4.tgz#5860587a944873a6b7e6d26e8e51ffb22315bf17"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
-sha.js@^2.4.0, sha.js@^2.4.8:
+sha.js@^2.4.0, sha.js@^2.4.11, sha.js@^2.4.8:
version "2.4.11"
resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7"
integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==
global "^4.3.2"
video.js "^6 || ^7"
+videojs-contrib-quality-levels@^2.0.9:
+ version "2.0.9"
+ resolved "https://registry.yarnpkg.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-2.0.9.tgz#b5d533d5092a6fc7d29eae1b43e4597d89bd527b"
+ integrity sha512-HJeaJJQdSufi9Y5T7jlyyhkeq+mWPCog86q6ypoTi66boBMMJTo2abiOSHS9KaOGAJjH72gfvrjVY5FRdjlxYA==
+ dependencies:
+ global "^4.3.2"
+ video.js "^6 || ^7"
+
videojs-dock@^2.0.2:
version "2.1.4"
resolved "https://registry.yarnpkg.com/videojs-dock/-/videojs-dock-2.1.4.tgz#0ebd198b5d48990e3523fdc87dbfdb9fe96f804c"
if [ ! -f "./client/dist/en_US/index.html" ]; then
echo "client/dist/en_US/index.html does not exist, compile client files..."
- npm run build:client
+ npm run build:client -- --light
fi
npm run watch:server
baseUri: ["'self'"],
manifestSrc: ["'self'"],
frameSrc: ["'self'"], // instead of deprecated child-src / self because of test-embed
- workerSrc: ["'self'"] // instead of deprecated child-src
+ workerSrc: ["'self'", 'blob:'] // instead of deprecated child-src
},
CONFIG.SERVICES['CSP-LOGGER'] ? { reportUri: CONFIG.SERVICES['CSP-LOGGER'] } : {},
CONFIG.WEBSERVER.SCHEME === 'https' ? { upgradeInsecureRequests: true } : {}