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