import debug from 'debug' import videojs from 'video.js' import { logger } from '@root-helpers/logger' import { isMobile } from '@root-helpers/web-browser' import { timeToInt } from '@shared/core-utils' import { VideoView, VideoViewEvent } from '@shared/models/videos' import { getStoredLastSubtitle, getStoredMute, getStoredVolume, saveLastSubtitle, saveMuteInStore, saveVideoWatchHistory, saveVolumeInStore } from '../../peertube-player-local-storage' import { PeerTubePluginOptions, VideoJSCaption } from '../../types' import { SettingsButton } from '../settings/settings-menu-button' const debugLogger = debug('peertube:player:peertube') const Plugin = videojs.getPlugin('plugin') class PeerTubePlugin extends Plugin { private readonly videoViewUrl: string private readonly authorizationHeader: () => string private readonly videoUUID: string private readonly startTime: number private readonly CONSTANTS = { USER_VIEW_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video } private videoCaptions: VideoJSCaption[] private defaultSubtitle: string private videoViewInterval: any private menuOpened = false private mouseInControlBar = false private mouseInSettings = false private readonly initialInactivityTimeout: number constructor (player: videojs.Player, options?: PeerTubePluginOptions) { super(player) this.videoViewUrl = options.videoViewUrl this.authorizationHeader = options.authorizationHeader this.videoUUID = options.videoUUID this.startTime = timeToInt(options.startTime) this.videoCaptions = options.videoCaptions this.initialInactivityTimeout = this.player.options_.inactivityTimeout if (options.autoplay) this.player.addClass('vjs-has-autoplay') this.player.on('autoplay-failure', () => { this.player.removeClass('vjs-has-autoplay') }) this.player.ready(() => { const playerOptions = this.player.options_ 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()) }) if (options.stopTime) { const stopTime = timeToInt(options.stopTime) const self = this this.player.on('timeupdate', function onTimeUpdate () { if (self.player.currentTime() > stopTime) { self.player.pause() self.player.trigger('stopped') self.player.off('timeupdate', onTimeUpdate) } }) } this.player.textTracks().addEventListener('change', () => { const showing = this.player.textTracks().tracks_.find(t => { 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.runUserViewing() }) } dispose () { if (this.videoViewInterval) clearInterval(this.videoViewInterval) } onMenuOpened () { this.menuOpened = true this.alterInactivity() } onMenuClosed () { this.menuOpened = false this.alterInactivity() } displayFatalError () { this.player.loadingSpinner.hide() const buildModal = (error: MediaError) => { const localize = this.player.localize.bind(this.player) const wrapper = document.createElement('div') const header = document.createElement('h1') header.innerText = localize('Failed to play video') wrapper.appendChild(header) const desc = document.createElement('div') desc.innerText = localize('The video failed to play due to technical issues.') wrapper.appendChild(desc) const details = document.createElement('p') details.classList.add('error-details') details.innerText = error.message wrapper.appendChild(details) return wrapper } const modal = this.player.createModal(buildModal(this.player.error()), { temporary: false, uncloseable: true }) modal.addClass('vjs-custom-error-display') this.player.addClass('vjs-error-display-enabled') } hideFatalError () { this.player.removeClass('vjs-error-display-enabled') } private initializePlayer () { if (isMobile()) this.player.addClass('vjs-is-mobile') this.initSmoothProgressBar() this.initCaptions() this.listenControlBarMouse() this.listenFullScreenChange() } // --------------------------------------------------------------------------- private runUserViewing () { let lastCurrentTime = this.startTime let lastViewEvent: VideoViewEvent this.player.one('play', () => { this.notifyUserIsWatching(this.startTime, lastViewEvent) }) this.player.on('seeked', () => { // Don't take into account small seek events if (Math.abs(this.player.currentTime() - lastCurrentTime) < 3) return lastViewEvent = 'seek' }) this.player.one('ended', () => { const currentTime = Math.round(this.player.duration()) lastCurrentTime = currentTime this.notifyUserIsWatching(currentTime, lastViewEvent) lastViewEvent = undefined }) this.videoViewInterval = setInterval(() => { const currentTime = Math.round(this.player.currentTime()) // No need to update if (currentTime === lastCurrentTime) return lastCurrentTime = currentTime this.notifyUserIsWatching(currentTime, lastViewEvent) .catch(err => logger.error('Cannot notify user is watching.', err)) lastViewEvent = undefined // Server won't save history, so save the video position in local storage if (!this.authorizationHeader()) { saveVideoWatchHistory(this.videoUUID, currentTime) } }, this.CONSTANTS.USER_VIEW_VIDEO_INTERVAL) } private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { if (!this.videoViewUrl) return Promise.resolve(undefined) const body: VideoView = { currentTime, viewEvent } const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) } // --------------------------------------------------------------------------- private listenFullScreenChange () { this.player.on('fullscreenchange', () => { if (this.player.isFullscreen()) this.player.focus() }) } private listenControlBarMouse () { const controlBar = this.player.controlBar const settingsButton: SettingsButton = (controlBar as any).settingsButton controlBar.on('mouseenter', () => { this.mouseInControlBar = true this.alterInactivity() }) controlBar.on('mouseleave', () => { this.mouseInControlBar = false this.alterInactivity() }) settingsButton.dialog.on('mouseenter', () => { this.mouseInSettings = true this.alterInactivity() }) settingsButton.dialog.on('mouseleave', () => { this.mouseInSettings = false this.alterInactivity() }) } private alterInactivity () { if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) { this.setInactivityTimeout(0) return } this.setInactivityTimeout(this.initialInactivityTimeout) this.player.reportUserActivity(true) } private setInactivityTimeout (timeout: number) { (this.player as any).cache_.inactivityTimeout = timeout this.player.options_.inactivityTimeout = timeout debugLogger('Set player inactivity to ' + timeout) } 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 = videojs.getComponent('SeekBar') as any 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 }