dd9408c8e280b37df8803cc3b9d96ddb36af24ef
[oweals/peertube.git] / client / src / assets / player / peertube-plugin.ts
1 // FIXME: something weird with our path definition in tsconfig and typings
2 // @ts-ignore
3 import * as videojs from 'video.js'
4 import './videojs-components/settings-menu-button'
5 import {
6   PeerTubePluginOptions,
7   ResolutionUpdateData,
8   UserWatching,
9   VideoJSCaption,
10   VideoJSComponentInterface,
11   videojsUntyped
12 } from './peertube-videojs-typings'
13 import { isMobile, timeToInt } from './utils'
14 import {
15   getStoredLastSubtitle,
16   getStoredMute,
17   getStoredVolume,
18   saveLastSubtitle,
19   saveMuteInStore,
20   saveVolumeInStore
21 } from './peertube-player-local-storage'
22
23 const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
24 class PeerTubePlugin extends Plugin {
25   private readonly videoViewUrl: string
26   private readonly videoDuration: number
27   private readonly CONSTANTS = {
28     USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
29   }
30
31   private player: any
32   private videoCaptions: VideoJSCaption[]
33   private defaultSubtitle: string
34
35   private videoViewInterval: any
36   private userWatchingVideoInterval: any
37   private lastResolutionChange: ResolutionUpdateData
38
39   constructor (player: videojs.Player, options: PeerTubePluginOptions) {
40     super(player, options)
41
42     this.videoViewUrl = options.videoViewUrl
43     this.videoDuration = options.videoDuration
44     this.videoCaptions = options.videoCaptions
45
46     if (options.autoplay === true) this.player.addClass('vjs-has-autoplay')
47
48     this.player.on('autoplay-failure', () => {
49       this.player.removeClass('vjs-has-autoplay')
50     })
51
52     this.player.ready(() => {
53       const playerOptions = this.player.options_
54
55       if (options.mode === 'webtorrent') {
56         this.player.webtorrent().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
57         this.player.webtorrent().on('autoResolutionChange', (_: any, d: any) => this.trigger('autoResolutionChange', d))
58       }
59
60       if (options.mode === 'p2p-media-loader') {
61         this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
62       }
63
64       this.player.tech_.on('loadedqualitydata', () => {
65         setTimeout(() => {
66           // Replay a resolution change, now we loaded all quality data
67           if (this.lastResolutionChange) this.handleResolutionChange(this.lastResolutionChange)
68         }, 0)
69       })
70
71       const volume = getStoredVolume()
72       if (volume !== undefined) this.player.volume(volume)
73
74       const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
75       if (muted !== undefined) this.player.muted(muted)
76
77       this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
78
79       this.player.on('volumechange', () => {
80         saveVolumeInStore(this.player.volume())
81         saveMuteInStore(this.player.muted())
82       })
83
84       if (options.stopTime) {
85         const stopTime = timeToInt(options.stopTime)
86         const self = this
87
88         this.player.on('timeupdate', function onTimeUpdate () {
89           if (self.player.currentTime() > stopTime) {
90             self.player.pause()
91             self.player.trigger('stopped')
92
93             self.player.off('timeupdate', onTimeUpdate)
94           }
95         })
96       }
97
98       this.player.textTracks().on('change', () => {
99         const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
100           return t.kind === 'captions' && t.mode === 'showing'
101         })
102
103         if (!showing) {
104           saveLastSubtitle('off')
105           return
106         }
107
108         saveLastSubtitle(showing.language)
109       })
110
111       this.player.on('sourcechange', () => this.initCaptions())
112
113       this.player.duration(options.videoDuration)
114
115       this.initializePlayer()
116       this.runViewAdd()
117
118       if (options.userWatching) this.runUserWatchVideo(options.userWatching)
119     })
120   }
121
122   dispose () {
123     if (this.videoViewInterval) clearInterval(this.videoViewInterval)
124     if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
125   }
126
127   private initializePlayer () {
128     if (isMobile()) this.player.addClass('vjs-is-mobile')
129
130     this.initSmoothProgressBar()
131
132     this.initCaptions()
133
134     this.alterInactivity()
135   }
136
137   private runViewAdd () {
138     this.clearVideoViewInterval()
139
140     // After 30 seconds (or 3/4 of the video), add a view to the video
141     let minSecondsToView = 30
142
143     if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4
144
145     let secondsViewed = 0
146     this.videoViewInterval = setInterval(() => {
147       if (this.player && !this.player.paused()) {
148         secondsViewed += 1
149
150         if (secondsViewed > minSecondsToView) {
151           this.clearVideoViewInterval()
152
153           this.addViewToVideo().catch(err => console.error(err))
154         }
155       }
156     }, 1000)
157   }
158
159   private runUserWatchVideo (options: UserWatching) {
160     let lastCurrentTime = 0
161
162     this.userWatchingVideoInterval = setInterval(() => {
163       const currentTime = Math.floor(this.player.currentTime())
164
165       if (currentTime - lastCurrentTime >= 1) {
166         lastCurrentTime = currentTime
167
168         this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
169           .catch(err => console.error('Cannot notify user is watching.', err))
170       }
171     }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
172   }
173
174   private clearVideoViewInterval () {
175     if (this.videoViewInterval !== undefined) {
176       clearInterval(this.videoViewInterval)
177       this.videoViewInterval = undefined
178     }
179   }
180
181   private addViewToVideo () {
182     if (!this.videoViewUrl) return Promise.resolve(undefined)
183
184     return fetch(this.videoViewUrl, { method: 'POST' })
185   }
186
187   private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
188     const body = new URLSearchParams()
189     body.append('currentTime', currentTime.toString())
190
191     const headers = new Headers({ 'Authorization': authorizationHeader })
192
193     return fetch(url, { method: 'PUT', body, headers })
194   }
195
196   private handleResolutionChange (data: ResolutionUpdateData) {
197     this.lastResolutionChange = data
198
199     const qualityLevels = this.player.qualityLevels()
200
201     for (let i = 0; i < qualityLevels.length; i++) {
202       if (qualityLevels[i].height === data.resolutionId) {
203         data.id = qualityLevels[i].id
204         break
205       }
206     }
207
208     this.trigger('resolutionChange', data)
209   }
210
211   private alterInactivity () {
212     let saveInactivityTimeout: number
213
214     const disableInactivity = () => {
215       saveInactivityTimeout = this.player.options_.inactivityTimeout
216       this.player.options_.inactivityTimeout = 0
217     }
218     const enableInactivity = () => {
219       this.player.options_.inactivityTimeout = saveInactivityTimeout
220     }
221
222     const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog')
223
224     this.player.controlBar.on('mouseenter', () => disableInactivity())
225     settingsDialog.on('mouseenter', () => disableInactivity())
226     this.player.controlBar.on('mouseleave', () => enableInactivity())
227     settingsDialog.on('mouseleave', () => enableInactivity())
228   }
229
230   private initCaptions () {
231     for (const caption of this.videoCaptions) {
232       this.player.addRemoteTextTrack({
233         kind: 'captions',
234         label: caption.label,
235         language: caption.language,
236         id: caption.language,
237         src: caption.src,
238         default: this.defaultSubtitle === caption.language
239       }, false)
240     }
241
242     this.player.trigger('captionsChanged')
243   }
244
245   // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
246   private initSmoothProgressBar () {
247     const SeekBar = videojsUntyped.getComponent('SeekBar')
248     SeekBar.prototype.getPercent = function getPercent () {
249       // Allows for smooth scrubbing, when player can't keep up.
250       // const time = (this.player_.scrubbing()) ?
251       //   this.player_.getCache().currentTime :
252       //   this.player_.currentTime()
253       const time = this.player_.currentTime()
254       const percent = time / this.player_.duration()
255       return percent >= 1 ? 1 : percent
256     }
257     SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
258       let newTime = this.calculateDistance(event) * this.player_.duration()
259       if (newTime === this.player_.duration()) {
260         newTime = newTime - 0.1
261       }
262       this.player_.currentTime(newTime)
263       this.update()
264     }
265   }
266 }
267
268 videojs.registerPlugin('peertube', PeerTubePlugin)
269 export { PeerTubePlugin }