1 import debug from 'debug'
2 import videojs from 'video.js'
3 import { logger } from '@root-helpers/logger'
4 import { isMobile } from '@root-helpers/web-browser'
5 import { timeToInt } from '@shared/core-utils'
6 import { VideoView, VideoViewEvent } from '@shared/models/videos'
13 saveVideoWatchHistory,
15 } from '../../peertube-player-local-storage'
16 import { PeerTubePluginOptions, VideoJSCaption } from '../../types'
17 import { SettingsButton } from '../settings/settings-menu-button'
19 const debugLogger = debug('peertube:player:peertube')
21 const Plugin = videojs.getPlugin('plugin')
23 class PeerTubePlugin extends Plugin {
24 private readonly videoViewUrl: string
25 private readonly authorizationHeader: () => string
27 private readonly videoUUID: string
28 private readonly startTime: number
30 private readonly videoViewIntervalMs: number
32 private videoCaptions: VideoJSCaption[]
33 private defaultSubtitle: string
35 private videoViewInterval: any
37 private menuOpened = false
38 private mouseInControlBar = false
39 private mouseInSettings = false
40 private readonly initialInactivityTimeout: number
42 constructor (player: videojs.Player, options?: PeerTubePluginOptions) {
45 this.videoViewUrl = options.videoViewUrl
46 this.authorizationHeader = options.authorizationHeader
47 this.videoUUID = options.videoUUID
48 this.startTime = timeToInt(options.startTime)
49 this.videoViewIntervalMs = options.videoViewIntervalMs
51 this.videoCaptions = options.videoCaptions
52 this.initialInactivityTimeout = this.player.options_.inactivityTimeout
54 if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay')
56 this.player.on('autoplay-failure', () => {
57 this.player.removeClass('vjs-has-autoplay')
60 this.player.ready(() => {
61 const playerOptions = this.player.options_
63 const volume = getStoredVolume()
64 if (volume !== undefined) this.player.volume(volume)
66 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
67 if (muted !== undefined) this.player.muted(muted)
69 this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
71 this.player.on('volumechange', () => {
72 saveVolumeInStore(this.player.volume())
73 saveMuteInStore(this.player.muted())
76 if (options.stopTime) {
77 const stopTime = timeToInt(options.stopTime)
80 this.player.on('timeupdate', function onTimeUpdate () {
81 if (self.player.currentTime() > stopTime) {
83 self.player.trigger('stopped')
85 self.player.off('timeupdate', onTimeUpdate)
90 this.player.textTracks().addEventListener('change', () => {
91 const showing = this.player.textTracks().tracks_.find(t => {
92 return t.kind === 'captions' && t.mode === 'showing'
96 saveLastSubtitle('off')
100 saveLastSubtitle(showing.language)
103 this.player.on('sourcechange', () => this.initCaptions())
105 this.player.duration(options.videoDuration)
107 this.initializePlayer()
108 this.runUserViewing()
113 if (this.videoViewInterval) clearInterval(this.videoViewInterval)
117 this.menuOpened = true
118 this.alterInactivity()
122 this.menuOpened = false
123 this.alterInactivity()
126 displayFatalError () {
127 this.player.loadingSpinner.hide()
129 const buildModal = (error: MediaError) => {
130 const localize = this.player.localize.bind(this.player)
132 const wrapper = document.createElement('div')
133 const header = document.createElement('h1')
134 header.innerText = localize('Failed to play video')
135 wrapper.appendChild(header)
136 const desc = document.createElement('div')
137 desc.innerText = localize('The video failed to play due to technical issues.')
138 wrapper.appendChild(desc)
139 const details = document.createElement('p')
140 details.classList.add('error-details')
141 details.innerText = error.message
142 wrapper.appendChild(details)
147 const modal = this.player.createModal(buildModal(this.player.error()), {
151 modal.addClass('vjs-custom-error-display')
153 this.player.addClass('vjs-error-display-enabled')
157 this.player.removeClass('vjs-error-display-enabled')
160 private initializePlayer () {
161 if (isMobile()) this.player.addClass('vjs-is-mobile')
163 this.initSmoothProgressBar()
167 this.listenControlBarMouse()
169 this.listenFullScreenChange()
172 // ---------------------------------------------------------------------------
174 private runUserViewing () {
175 let lastCurrentTime = this.startTime
176 let lastViewEvent: VideoViewEvent
178 this.player.one('play', () => {
179 this.notifyUserIsWatching(this.startTime, lastViewEvent)
182 this.player.on('seeked', () => {
183 const diff = Math.floor(this.player.currentTime()) - lastCurrentTime
185 // Don't take into account small forwards
186 if (diff > 0 && diff < 3) return
188 lastViewEvent = 'seek'
191 this.player.one('ended', () => {
192 const currentTime = Math.floor(this.player.duration())
193 lastCurrentTime = currentTime
195 this.notifyUserIsWatching(currentTime, lastViewEvent)
197 lastViewEvent = undefined
200 this.videoViewInterval = setInterval(() => {
201 const currentTime = Math.floor(this.player.currentTime())
204 if (currentTime === lastCurrentTime) return
206 lastCurrentTime = currentTime
208 this.notifyUserIsWatching(currentTime, lastViewEvent)
209 .catch(err => logger.error('Cannot notify user is watching.', err))
211 lastViewEvent = undefined
212 }, this.videoViewIntervalMs)
215 private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
216 // Server won't save history, so save the video position in local storage
217 if (!this.authorizationHeader()) {
218 saveVideoWatchHistory(this.videoUUID, currentTime)
221 if (!this.videoViewUrl) return Promise.resolve(true)
223 const body: VideoView = { currentTime, viewEvent }
225 const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
226 if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
228 return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers })
231 // ---------------------------------------------------------------------------
233 private listenFullScreenChange () {
234 this.player.on('fullscreenchange', () => {
235 if (this.player.isFullscreen()) this.player.focus()
239 private listenControlBarMouse () {
240 const controlBar = this.player.controlBar
241 const settingsButton: SettingsButton = (controlBar as any).settingsButton
243 controlBar.on('mouseenter', () => {
244 this.mouseInControlBar = true
245 this.alterInactivity()
248 controlBar.on('mouseleave', () => {
249 this.mouseInControlBar = false
250 this.alterInactivity()
253 settingsButton.dialog.on('mouseenter', () => {
254 this.mouseInSettings = true
255 this.alterInactivity()
258 settingsButton.dialog.on('mouseleave', () => {
259 this.mouseInSettings = false
260 this.alterInactivity()
264 private alterInactivity () {
265 if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) {
266 this.setInactivityTimeout(0)
270 this.setInactivityTimeout(this.initialInactivityTimeout)
271 this.player.reportUserActivity(true)
274 private setInactivityTimeout (timeout: number) {
275 (this.player as any).cache_.inactivityTimeout = timeout
276 this.player.options_.inactivityTimeout = timeout
278 debugLogger('Set player inactivity to ' + timeout)
281 private initCaptions () {
282 for (const caption of this.videoCaptions) {
283 this.player.addRemoteTextTrack({
285 label: caption.label,
286 language: caption.language,
287 id: caption.language,
289 default: this.defaultSubtitle === caption.language
293 this.player.trigger('captionsChanged')
296 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
297 private initSmoothProgressBar () {
298 const SeekBar = videojs.getComponent('SeekBar') as any
299 SeekBar.prototype.getPercent = function getPercent () {
300 // Allows for smooth scrubbing, when player can't keep up.
301 // const time = (this.player_.scrubbing()) ?
302 // this.player_.getCache().currentTime :
303 // this.player_.currentTime()
304 const time = this.player_.currentTime()
305 const percent = time / this.player_.duration()
306 return percent >= 1 ? 1 : percent
308 SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
309 let newTime = this.calculateDistance(event) * this.player_.duration()
310 if (newTime === this.player_.duration()) {
311 newTime = newTime - 0.1
313 this.player_.currentTime(newTime)
319 videojs.registerPlugin('peertube', PeerTubePlugin)
320 export { PeerTubePlugin }