import debug from 'debug' import videojs from 'video.js' import { timeToInt } from '@peertube/peertube-core-utils' import { VideoView, VideoViewEvent } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { isIOS, isMobile, isSafari } from '@root-helpers/web-browser' import { getStoredLastSubtitle, getStoredMute, getStoredVolume, saveLastSubtitle, saveMuteInStore, saveVideoWatchHistory, saveVolumeInStore } from '../../peertube-player-local-storage' import { PeerTubePluginOptions } 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 initialInactivityTimeout: number private readonly hasAutoplay: () => videojs.Autoplay private currentSubtitle: string private currentPlaybackRate: number private videoViewInterval: any private menuOpened = false private mouseInControlBar = false private mouseInSettings = false private errorModal: videojs.ModalDialog private hasInitialSeek = false private videoViewOnPlayHandler: (...args: any[]) => void private videoViewOnSeekedHandler: (...args: any[]) => void private videoViewOnEndedHandler: (...args: any[]) => void private stopTimeHandler: (...args: any[]) => void constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) { super(player) this.videoViewUrl = options.videoViewUrl this.authorizationHeader = options.authorizationHeader this.hasAutoplay = options.hasAutoplay this.initialInactivityTimeout = this.player.options_.inactivityTimeout this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle() this.initializePlayer() this.initOnVideoChange() this.player.removeClass('vjs-can-play') this.deleteLegacyIndexedDB() this.player.on('autoplay-failure', () => { debugLogger('Autoplay failed') this.player.removeClass('vjs-has-autoplay') this.player.poster(options.poster()) // Fix a bug on iOS/Safari where the big play button is not displayed when autoplay fails if (isIOS() || isSafari()) this.player.hasStarted(false) }) this.player.on('ratechange', () => { this.currentPlaybackRate = this.player.playbackRate() this.player.defaultPlaybackRate(this.currentPlaybackRate) }) this.player.one('canplay', () => { 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.addClass('vjs-can-play') }) this.player.ready(() => { this.player.on('volumechange', () => { saveVolumeInStore(this.player.volume()) saveMuteInStore(this.player.muted()) }) this.player.textTracks().addEventListener('change', () => { const showing = this.player.textTracks().tracks_.find(t => { return t.kind === 'captions' && t.mode === 'showing' }) if (!showing) { saveLastSubtitle('off') this.currentSubtitle = undefined return } this.currentSubtitle = showing.language saveLastSubtitle(showing.language) }) this.player.on('video-change', () => { this.initOnVideoChange() this.hideFatalError() }) }) this.initOnRatioChange() } dispose () { if (this.videoViewInterval) clearInterval(this.videoViewInterval) super.dispose() } onMenuOpened () { this.menuOpened = true this.alterInactivity() } onMenuClosed () { this.menuOpened = false this.alterInactivity() } displayFatalError () { // Already displayed an error if (this.errorModal) return debugLogger('Display fatal error') 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 } this.errorModal = this.player.createModal(buildModal(this.player.error()), { temporary: true, uncloseable: true }) this.errorModal.addClass('vjs-custom-error-display') this.player.addClass('vjs-error-display-enabled') } hideFatalError () { if (!this.errorModal) return debugLogger('Hiding fatal error') this.player.removeClass('vjs-error-display-enabled') this.player.removeChild(this.errorModal) this.errorModal.close() this.errorModal = undefined } private initializePlayer () { if (isMobile()) this.player.addClass('vjs-is-mobile') this.initSmoothProgressBar() this.player.ready(() => { this.listenControlBarMouse() }) this.listenFullScreenChange() } private initOnVideoChange () { if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay') else this.player.removeClass('vjs-has-autoplay') if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) { debugLogger('Setting playback rate to ' + this.currentPlaybackRate) this.player.playbackRate(this.currentPlaybackRate) } this.player.ready(() => { this.initCaptions() this.updateControlBar() }) this.handleStartStopTime() this.runUserViewing() } private initOnRatioChange () { if (!this.options.autoPlayerRatio) return const defaultRatio = getComputedStyle(this.player.el()).getPropertyValue(this.options.autoPlayerRatio.cssRatioVariable) this.player.on('video-ratio-changed', (_event, data: { ratio: number }) => { const el = this.player.el() as HTMLElement // In portrait screen mode, we allow player with bigger height size than width const portraitMode = getComputedStyle(el).getPropertyValue(this.options.autoPlayerRatio.cssPlayerPortraitModeVariable) === '1' const currentRatio = isNaN(data.ratio) || (!portraitMode && data.ratio < 1) ? defaultRatio : data.ratio el.style.setProperty('--player-ratio', currentRatio + '') }) } // --------------------------------------------------------------------------- private runUserViewing () { const startTime = timeToInt(this.options.startTime()) let lastCurrentTime = startTime let lastViewEvent: VideoViewEvent let ended = false // player.ended() is too "slow", so handle ended manually if (this.videoViewInterval) clearInterval(this.videoViewInterval) if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler) if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler) if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler) this.videoViewOnPlayHandler = () => { debugLogger('Notify user is watching on play: ' + startTime) this.notifyUserIsWatching(startTime, lastViewEvent) } this.videoViewOnSeekedHandler = () => { // Bypass the first initial seek if (this.hasInitialSeek) { this.hasInitialSeek = false return } const diff = Math.floor(this.player.currentTime()) - lastCurrentTime // Don't take into account small forwards if (diff > 0 && diff < 3) return debugLogger('Detected seek event for user watching') lastViewEvent = 'seek' } this.videoViewOnEndedHandler = () => { ended = true if (this.options.isLive()) return const currentTime = Math.floor(this.player.duration()) lastCurrentTime = currentTime debugLogger('Notify user is watching on end: ' + currentTime) this.notifyUserIsWatching(currentTime, lastViewEvent) lastViewEvent = undefined } this.player.one('play', this.videoViewOnPlayHandler) this.player.on('seeked', this.videoViewOnSeekedHandler) this.player.one('ended', this.videoViewOnEndedHandler) this.videoViewInterval = setInterval(() => { if (ended) return const currentTime = Math.floor(this.player.currentTime()) // No need to update if (currentTime === lastCurrentTime) return debugLogger('Notify user is watching: ' + currentTime) lastCurrentTime = currentTime this.notifyUserIsWatching(currentTime, lastViewEvent) .catch(err => logger.error('Cannot notify user is watching.', err)) lastViewEvent = undefined }, this.options.videoViewIntervalMs) } private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { // Server won't save history, so save the video position in local storage if (!this.authorizationHeader()) { saveVideoWatchHistory(this.options.videoUUID(), currentTime) } if (!this.videoViewUrl()) return Promise.resolve(true) 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 } private initCaptions () { if (this.currentSubtitle) debugLogger('Init captions with current subtitle ' + this.currentSubtitle) else debugLogger('Init captions without current subtitle') this.player.tech(true).clearTracks('text') for (const caption of this.options.videoCaptions()) { this.player.addRemoteTextTrack({ kind: 'captions', label: caption.label, language: caption.language, id: caption.language, src: caption.src, default: this.currentSubtitle === caption.language }, true) } this.player.trigger('captions-changed') } private updateControlBar () { debugLogger('Updating control bar') if (this.options.isLive()) { this.getPlaybackRateButton().hide() this.player.controlBar.getChild('progressControl').hide() this.player.controlBar.getChild('currentTimeDisplay').hide() this.player.controlBar.getChild('timeDivider').hide() this.player.controlBar.getChild('durationDisplay').hide() this.player.controlBar.getChild('peerTubeLiveDisplay').show() } else { this.getPlaybackRateButton().show() this.player.controlBar.getChild('progressControl').show() this.player.controlBar.getChild('currentTimeDisplay').show() this.player.controlBar.getChild('timeDivider').show() this.player.controlBar.getChild('durationDisplay').show() this.player.controlBar.getChild('peerTubeLiveDisplay').hide() } if (this.options.videoCaptions().length === 0) { this.getCaptionsButton().hide() } else { this.getCaptionsButton().show() } } private handleStartStopTime () { this.player.duration(this.options.videoDuration()) if (this.stopTimeHandler) { this.player.off('timeupdate', this.stopTimeHandler) this.stopTimeHandler = undefined } // Prefer canplaythrough instead of canplay because Chrome has issues with the second one this.player.one('canplaythrough', () => { const startTime = this.options.startTime() if (startTime !== null && startTime !== undefined) { debugLogger('Start the video at ' + startTime) this.hasInitialSeek = true this.player.currentTime(timeToInt(startTime)) } if (this.options.stopTime()) { const stopTime = timeToInt(this.options.stopTime()) this.stopTimeHandler = () => { if (this.player.currentTime() <= stopTime) return debugLogger('Stopping the video at ' + this.options.stopTime()) // Time top stop this.player.pause() this.player.trigger('auto-stopped') this.player.off('timeupdate', this.stopTimeHandler) this.stopTimeHandler = undefined } this.player.on('timeupdate', this.stopTimeHandler) } }) } // 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() } } private getCaptionsButton () { const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton } private getPlaybackRateButton () { const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton return settingsButton.menu.getChild('playbackRateMenuButton') } // We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB private deleteLegacyIndexedDB () { try { if (typeof window.indexedDB === 'undefined') return if (!window.indexedDB) return if (typeof window.indexedDB.databases !== 'function') return window.indexedDB.databases() .then(databases => { for (const db of databases) { window.indexedDB.deleteDatabase(db.name) } }) } catch (err) { debugLogger('Cannot delete legacy indexed DB', err) // Nothing to do } } } videojs.registerPlugin('peertube', PeerTubePlugin) export { PeerTubePlugin }