]>
Commit | Line | Data |
---|---|---|
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' | |
7 | import { | |
8 | getStoredLastSubtitle, | |
9 | getStoredMute, | |
10 | getStoredVolume, | |
11 | saveLastSubtitle, | |
12 | saveMuteInStore, | |
13 | saveVideoWatchHistory, | |
14 | saveVolumeInStore | |
15 | } from '../../peertube-player-local-storage' | |
16 | import { PeerTubePluginOptions, VideoJSCaption } from '../../types' | |
17 | import { SettingsButton } from '../settings/settings-menu-button' | |
18 | ||
19 | const debugLogger = debug('peertube:player:peertube') | |
20 | ||
21 | const Plugin = videojs.getPlugin('plugin') | |
22 | ||
23 | class PeerTubePlugin extends Plugin { | |
24 | private readonly videoViewUrl: string | |
25 | private readonly authorizationHeader: () => string | |
26 | ||
27 | private readonly videoUUID: string | |
28 | private readonly startTime: number | |
29 | ||
30 | private readonly videoViewIntervalMs: number | |
31 | ||
32 | private videoCaptions: VideoJSCaption[] | |
33 | private defaultSubtitle: string | |
34 | ||
35 | private videoViewInterval: any | |
36 | ||
37 | private menuOpened = false | |
38 | private mouseInControlBar = false | |
39 | private mouseInSettings = false | |
40 | private readonly initialInactivityTimeout: number | |
41 | ||
42 | constructor (player: videojs.Player, options?: PeerTubePluginOptions) { | |
43 | super(player) | |
44 | ||
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 | |
50 | ||
51 | this.videoCaptions = options.videoCaptions | |
52 | this.initialInactivityTimeout = this.player.options_.inactivityTimeout | |
53 | ||
54 | if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay') | |
55 | ||
56 | this.player.on('autoplay-failure', () => { | |
57 | this.player.removeClass('vjs-has-autoplay') | |
58 | }) | |
59 | ||
60 | this.player.ready(() => { | |
61 | const playerOptions = this.player.options_ | |
62 | ||
63 | const volume = getStoredVolume() | |
64 | if (volume !== undefined) this.player.volume(volume) | |
65 | ||
66 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | |
67 | if (muted !== undefined) this.player.muted(muted) | |
68 | ||
69 | this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() | |
70 | ||
71 | this.player.on('volumechange', () => { | |
72 | saveVolumeInStore(this.player.volume()) | |
73 | saveMuteInStore(this.player.muted()) | |
74 | }) | |
75 | ||
76 | if (options.stopTime) { | |
77 | const stopTime = timeToInt(options.stopTime) | |
78 | const self = this | |
79 | ||
80 | this.player.on('timeupdate', function onTimeUpdate () { | |
81 | if (self.player.currentTime() > stopTime) { | |
82 | self.player.pause() | |
83 | self.player.trigger('stopped') | |
84 | ||
85 | self.player.off('timeupdate', onTimeUpdate) | |
86 | } | |
87 | }) | |
88 | } | |
89 | ||
90 | this.player.textTracks().addEventListener('change', () => { | |
91 | const showing = this.player.textTracks().tracks_.find(t => { | |
92 | return t.kind === 'captions' && t.mode === 'showing' | |
93 | }) | |
94 | ||
95 | if (!showing) { | |
96 | saveLastSubtitle('off') | |
97 | return | |
98 | } | |
99 | ||
100 | saveLastSubtitle(showing.language) | |
101 | }) | |
102 | ||
103 | this.player.on('sourcechange', () => this.initCaptions()) | |
104 | ||
105 | this.player.duration(options.videoDuration) | |
106 | ||
107 | this.initializePlayer() | |
108 | this.runUserViewing() | |
109 | }) | |
110 | } | |
111 | ||
112 | dispose () { | |
113 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) | |
114 | } | |
115 | ||
116 | onMenuOpened () { | |
117 | this.menuOpened = true | |
118 | this.alterInactivity() | |
119 | } | |
120 | ||
121 | onMenuClosed () { | |
122 | this.menuOpened = false | |
123 | this.alterInactivity() | |
124 | } | |
125 | ||
126 | displayFatalError () { | |
127 | this.player.loadingSpinner.hide() | |
128 | ||
129 | const buildModal = (error: MediaError) => { | |
130 | const localize = this.player.localize.bind(this.player) | |
131 | ||
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) | |
143 | ||
144 | return wrapper | |
145 | } | |
146 | ||
147 | const modal = this.player.createModal(buildModal(this.player.error()), { | |
148 | temporary: false, | |
149 | uncloseable: true | |
150 | }) | |
151 | modal.addClass('vjs-custom-error-display') | |
152 | ||
153 | this.player.addClass('vjs-error-display-enabled') | |
154 | } | |
155 | ||
156 | hideFatalError () { | |
157 | this.player.removeClass('vjs-error-display-enabled') | |
158 | } | |
159 | ||
160 | private initializePlayer () { | |
161 | if (isMobile()) this.player.addClass('vjs-is-mobile') | |
162 | ||
163 | this.initSmoothProgressBar() | |
164 | ||
165 | this.initCaptions() | |
166 | ||
167 | this.listenControlBarMouse() | |
168 | ||
169 | this.listenFullScreenChange() | |
170 | } | |
171 | ||
172 | // --------------------------------------------------------------------------- | |
173 | ||
174 | private runUserViewing () { | |
175 | let lastCurrentTime = this.startTime | |
176 | let lastViewEvent: VideoViewEvent | |
177 | ||
178 | this.player.one('play', () => { | |
179 | this.notifyUserIsWatching(this.startTime, lastViewEvent) | |
180 | }) | |
181 | ||
182 | this.player.on('seeked', () => { | |
183 | // Don't take into account small seek events | |
184 | if (Math.abs(this.player.currentTime() - lastCurrentTime) < 3) return | |
185 | ||
186 | lastViewEvent = 'seek' | |
187 | }) | |
188 | ||
189 | this.player.one('ended', () => { | |
190 | const currentTime = Math.floor(this.player.duration()) | |
191 | lastCurrentTime = currentTime | |
192 | ||
193 | this.notifyUserIsWatching(currentTime, lastViewEvent) | |
194 | ||
195 | lastViewEvent = undefined | |
196 | }) | |
197 | ||
198 | this.videoViewInterval = setInterval(() => { | |
199 | const currentTime = Math.floor(this.player.currentTime()) | |
200 | ||
201 | // No need to update | |
202 | if (currentTime === lastCurrentTime) return | |
203 | ||
204 | lastCurrentTime = currentTime | |
205 | ||
206 | this.notifyUserIsWatching(currentTime, lastViewEvent) | |
207 | .catch(err => logger.error('Cannot notify user is watching.', err)) | |
208 | ||
209 | lastViewEvent = undefined | |
210 | }, this.videoViewIntervalMs) | |
211 | } | |
212 | ||
213 | private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { | |
214 | // Server won't save history, so save the video position in local storage | |
215 | if (!this.authorizationHeader()) { | |
216 | saveVideoWatchHistory(this.videoUUID, currentTime) | |
217 | } | |
218 | ||
219 | if (!this.videoViewUrl) return Promise.resolve(true) | |
220 | ||
221 | const body: VideoView = { currentTime, viewEvent } | |
222 | ||
223 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) | |
224 | if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) | |
225 | ||
226 | return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) | |
227 | } | |
228 | ||
229 | // --------------------------------------------------------------------------- | |
230 | ||
231 | private listenFullScreenChange () { | |
232 | this.player.on('fullscreenchange', () => { | |
233 | if (this.player.isFullscreen()) this.player.focus() | |
234 | }) | |
235 | } | |
236 | ||
237 | private listenControlBarMouse () { | |
238 | const controlBar = this.player.controlBar | |
239 | const settingsButton: SettingsButton = (controlBar as any).settingsButton | |
240 | ||
241 | controlBar.on('mouseenter', () => { | |
242 | this.mouseInControlBar = true | |
243 | this.alterInactivity() | |
244 | }) | |
245 | ||
246 | controlBar.on('mouseleave', () => { | |
247 | this.mouseInControlBar = false | |
248 | this.alterInactivity() | |
249 | }) | |
250 | ||
251 | settingsButton.dialog.on('mouseenter', () => { | |
252 | this.mouseInSettings = true | |
253 | this.alterInactivity() | |
254 | }) | |
255 | ||
256 | settingsButton.dialog.on('mouseleave', () => { | |
257 | this.mouseInSettings = false | |
258 | this.alterInactivity() | |
259 | }) | |
260 | } | |
261 | ||
262 | private alterInactivity () { | |
263 | if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) { | |
264 | this.setInactivityTimeout(0) | |
265 | return | |
266 | } | |
267 | ||
268 | this.setInactivityTimeout(this.initialInactivityTimeout) | |
269 | this.player.reportUserActivity(true) | |
270 | } | |
271 | ||
272 | private setInactivityTimeout (timeout: number) { | |
273 | (this.player as any).cache_.inactivityTimeout = timeout | |
274 | this.player.options_.inactivityTimeout = timeout | |
275 | ||
276 | debugLogger('Set player inactivity to ' + timeout) | |
277 | } | |
278 | ||
279 | private initCaptions () { | |
280 | for (const caption of this.videoCaptions) { | |
281 | this.player.addRemoteTextTrack({ | |
282 | kind: 'captions', | |
283 | label: caption.label, | |
284 | language: caption.language, | |
285 | id: caption.language, | |
286 | src: caption.src, | |
287 | default: this.defaultSubtitle === caption.language | |
288 | }, false) | |
289 | } | |
290 | ||
291 | this.player.trigger('captionsChanged') | |
292 | } | |
293 | ||
294 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | |
295 | private initSmoothProgressBar () { | |
296 | const SeekBar = videojs.getComponent('SeekBar') as any | |
297 | SeekBar.prototype.getPercent = function getPercent () { | |
298 | // Allows for smooth scrubbing, when player can't keep up. | |
299 | // const time = (this.player_.scrubbing()) ? | |
300 | // this.player_.getCache().currentTime : | |
301 | // this.player_.currentTime() | |
302 | const time = this.player_.currentTime() | |
303 | const percent = time / this.player_.duration() | |
304 | return percent >= 1 ? 1 : percent | |
305 | } | |
306 | SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { | |
307 | let newTime = this.calculateDistance(event) * this.player_.duration() | |
308 | if (newTime === this.player_.duration()) { | |
309 | newTime = newTime - 0.1 | |
310 | } | |
311 | this.player_.currentTime(newTime) | |
312 | this.update() | |
313 | } | |
314 | } | |
315 | } | |
316 | ||
317 | videojs.registerPlugin('peertube', PeerTubePlugin) | |
318 | export { PeerTubePlugin } |