]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/assets/player/p2p-media-loader/hls-plugin.ts
Add fixme info
[github/Chocobozzz/PeerTube.git] / client / src / assets / player / p2p-media-loader / hls-plugin.ts
1 // Thanks https://github.com/streamroot/videojs-hlsjs-plugin
2 // We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file
3
4 import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
5 import videojs from 'video.js'
6 import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../peertube-videojs-typings'
7
8 type ErrorCounts = {
9 [ type: string ]: number
10 }
11
12 type Metadata = {
13 levels: Level[]
14 }
15
16 type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
17
18 const registerSourceHandler = function (vjs: typeof videojs) {
19 if (!Hlsjs.isSupported()) {
20 console.warn('Hls.js is not supported in this browser!')
21 return
22 }
23
24 const html5 = vjs.getTech('Html5')
25
26 if (!html5) {
27 console.error('Not supported version if video.js')
28 return
29 }
30
31 // FIXME: typings
32 (html5 as any).registerSourceHandler({
33 canHandleSource: function (source: videojs.Tech.SourceObject) {
34 const hlsTypeRE = /^application\/x-mpegURL|application\/vnd\.apple\.mpegurl$/i
35 const hlsExtRE = /\.m3u8/i
36
37 if (hlsTypeRE.test(source.type)) return 'probably'
38 if (hlsExtRE.test(source.src)) return 'maybe'
39
40 return ''
41 },
42
43 handleSource: function (source: videojs.Tech.SourceObject, tech: VideoJSTechHLS) {
44 if (tech.hlsProvider) {
45 tech.hlsProvider.dispose()
46 }
47
48 tech.hlsProvider = new Html5Hlsjs(vjs, source, tech)
49
50 return tech.hlsProvider
51 }
52 }, 0);
53
54 // FIXME: typings
55 (vjs as any).Html5Hlsjs = Html5Hlsjs
56 }
57
58 function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) {
59 const player = this
60
61 if (!options) return
62
63 if (!player.srOptions_) {
64 player.srOptions_ = {}
65 }
66
67 if (!player.srOptions_.hlsjsConfig) {
68 player.srOptions_.hlsjsConfig = options.hlsjsConfig
69 }
70
71 if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) {
72 player.srOptions_.levelLabelHandler = options.levelLabelHandler
73 }
74 }
75
76 const registerConfigPlugin = function (vjs: typeof videojs) {
77 // Used in Brightcove since we don't pass options directly there
78 const registerVjsPlugin = vjs.registerPlugin || vjs.plugin
79 registerVjsPlugin('hlsjs', hlsjsConfigHandler)
80 }
81
82 class Html5Hlsjs {
83 private static readonly hooks: { [id: string]: HookFn[] } = {}
84
85 private readonly videoElement: HTMLVideoElement
86 private readonly errorCounts: ErrorCounts = {}
87 private readonly player: videojs.Player
88 private readonly tech: videojs.Tech
89 private readonly source: videojs.Tech.SourceObject
90 private readonly vjs: typeof videojs
91
92 private maxNetworkErrorRecovery = 5
93
94 private hls: Hlsjs
95 private hlsjsConfig: Partial<HlsConfig & { cueHandler: any }> = null
96
97 private _duration: number = null
98 private metadata: Metadata = null
99 private isLive: boolean = null
100 private dvrDuration: number = null
101 private edgeMargin: number = null
102
103 private handlers: { [ id in 'play' ]: EventListener } = {
104 play: null
105 }
106
107 constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
108 this.vjs = vjs
109 this.source = source
110
111 this.tech = tech;
112 (this.tech as any).name_ = 'Hlsjs'
113
114 this.videoElement = tech.el() as HTMLVideoElement
115 this.player = vjs((tech.options_ as any).playerId)
116
117 this.videoElement.addEventListener('error', event => {
118 let errorTxt: string
119 const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error
120
121 if (!mediaError) return
122
123 console.log(mediaError)
124 switch (mediaError.code) {
125 case mediaError.MEDIA_ERR_ABORTED:
126 errorTxt = 'You aborted the video playback'
127 break
128 case mediaError.MEDIA_ERR_DECODE:
129 errorTxt = 'The video playback was aborted due to a corruption problem or because the video used features ' +
130 'your browser did not support'
131 this._handleMediaError(mediaError)
132 break
133 case mediaError.MEDIA_ERR_NETWORK:
134 errorTxt = 'A network error caused the video download to fail part-way'
135 break
136 case mediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
137 errorTxt = 'The video could not be loaded, either because the server or network failed or because the format is not supported'
138 break
139
140 default:
141 errorTxt = mediaError.message
142 }
143
144 console.error('MEDIA_ERROR: ', errorTxt)
145 })
146
147 this.initialize()
148 }
149
150 duration () {
151 if (this._duration === Infinity) return Infinity
152 if (!isNaN(this.videoElement.duration)) return this.videoElement.duration
153
154 return this._duration || 0
155 }
156
157 seekable () {
158 if (this.hls.media) {
159 if (!this.isLive) {
160 return this.vjs.createTimeRanges(0, this.hls.media.duration)
161 }
162
163 // Video.js doesn't seem to like floating point timeranges
164 const startTime = Math.round(this.hls.media.duration - this.dvrDuration)
165 const endTime = Math.round(this.hls.media.duration - this.edgeMargin)
166
167 return this.vjs.createTimeRanges(startTime, endTime)
168 }
169
170 return this.vjs.createTimeRanges()
171 }
172
173 // See comment for `initialize` method.
174 dispose () {
175 this.videoElement.removeEventListener('play', this.handlers.play)
176
177 this.hls.destroy()
178 }
179
180 static addHook (type: string, callback: HookFn) {
181 Html5Hlsjs.hooks[type] = this.hooks[type] || []
182 Html5Hlsjs.hooks[type].push(callback)
183 }
184
185 static removeHook (type: string, callback: HookFn) {
186 if (Html5Hlsjs.hooks[type] === undefined) return false
187
188 const index = Html5Hlsjs.hooks[type].indexOf(callback)
189 if (index === -1) return false
190
191 Html5Hlsjs.hooks[type].splice(index, 1)
192
193 return true
194 }
195
196 private _executeHooksFor (type: string) {
197 if (Html5Hlsjs.hooks[type] === undefined) {
198 return
199 }
200
201 // ES3 and IE < 9
202 for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) {
203 Html5Hlsjs.hooks[type][i](this.player, this.hls)
204 }
205 }
206
207 private _handleMediaError (error: any) {
208 if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 1) {
209 console.info('trying to recover media error')
210 this.hls.recoverMediaError()
211 return
212 }
213
214 if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 2) {
215 console.info('2nd try to recover media error (by swapping audio codec')
216 this.hls.swapAudioCodec()
217 this.hls.recoverMediaError()
218 return
219 }
220
221 if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] > 2) {
222 console.info('bubbling media error up to VIDEOJS')
223 this.hls.destroy()
224 this.tech.error = () => error
225 this.tech.trigger('error')
226 }
227 }
228
229 private _handleNetworkError (error: any) {
230 if (this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] <= this.maxNetworkErrorRecovery) {
231 console.info('trying to recover network error')
232
233 // Wait 1 second and retry
234 setTimeout(() => this.hls.startLoad(), 1000)
235
236 // Reset error count on success
237 this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
238 this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] = 0
239 })
240
241 return
242 }
243
244 console.info('bubbling network error up to VIDEOJS')
245 this.hls.destroy()
246 this.tech.error = () => error
247 this.tech.trigger('error')
248 }
249
250 private _onError (_event: any, data: ErrorData) {
251 const error: { message: string, code?: number } = {
252 message: `HLS.js error: ${data.type} - fatal: ${data.fatal} - ${data.details}`
253 }
254
255 // increment/set error count
256 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
257 else this.errorCounts[data.type] = 1
258
259 if (data.fatal) console.warn(error.message)
260 else console.error(error.message, data)
261
262 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
263 error.code = 2
264 this._handleNetworkError(error)
265 } else if (data.fatal && data.type === Hlsjs.ErrorTypes.MEDIA_ERROR && data.details !== 'manifestIncompatibleCodecsError') {
266 error.code = 3
267 this._handleMediaError(error)
268 } else if (data.fatal) {
269 this.hls.destroy()
270 console.info('bubbling error up to VIDEOJS')
271 this.tech.error = () => error as any
272 this.tech.trigger('error')
273 }
274 }
275
276 private buildLevelLabel (level: Level) {
277 if (this.player.srOptions_.levelLabelHandler) {
278 return this.player.srOptions_.levelLabelHandler(level as any)
279 }
280
281 if (level.height) return level.height + 'p'
282 if (level.width) return Math.round(level.width * 9 / 16) + 'p'
283 if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
284
285 return '0'
286 }
287
288 private _notifyVideoQualities () {
289 if (!this.metadata) return
290
291 const resolutions: PeerTubeResolution[] = []
292
293 this.metadata.levels.forEach((level, index) => {
294 resolutions.push({
295 id: index,
296 height: level.height,
297 width: level.width,
298 bitrate: level.bitrate,
299 label: this.buildLevelLabel(level),
300 selected: level.id === this.hls.manualLevel,
301
302 selectCallback: () => {
303 this.hls.currentLevel = index
304 }
305 })
306 })
307
308 resolutions.push({
309 id: -1,
310 label: this.player.localize('Auto'),
311 selected: true,
312 selectCallback: () => this.hls.currentLevel = -1
313 })
314
315 this.player.peertubeResolutions().add(resolutions)
316 }
317
318 private _startLoad () {
319 this.hls.startLoad(-1)
320 this.videoElement.removeEventListener('play', this.handlers.play)
321 }
322
323 private _oneLevelObjClone (obj: { [ id: string ]: any }) {
324 const result = {}
325 const objKeys = Object.keys(obj)
326 for (let i = 0; i < objKeys.length; i++) {
327 result[objKeys[i]] = obj[objKeys[i]]
328 }
329
330 return result
331 }
332
333 private _onMetaData (_event: any, data: ManifestParsedData) {
334 // This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
335 this.metadata = data
336 this._notifyVideoQualities()
337 }
338
339 private _initHlsjs () {
340 const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
341 const srOptions_ = this.player.srOptions_
342
343 const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
344 // Hls.js will write to the reference thus change the object for later streams
345 this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {}
346
347 if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
348 this.hlsjsConfig.autoStartLoad = false
349 }
350
351 // If the user explicitly sets autoStartLoad to false, we're not going to enter the if block above
352 // That's why we have a separate if block here to set the 'play' listener
353 if (this.hlsjsConfig.autoStartLoad === false) {
354 this.handlers.play = this._startLoad.bind(this)
355 this.videoElement.addEventListener('play', this.handlers.play)
356 }
357
358 this.hls = new Hlsjs(this.hlsjsConfig)
359
360 this._executeHooksFor('beforeinitialize')
361
362 this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
363 this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
364 this.hls.on(Hlsjs.Events.LEVEL_LOADED, (event, data) => {
365 // The DVR plugin will auto seek to "live edge" on start up
366 if (this.hlsjsConfig.liveSyncDuration) {
367 this.edgeMargin = this.hlsjsConfig.liveSyncDuration
368 } else if (this.hlsjsConfig.liveSyncDurationCount) {
369 this.edgeMargin = this.hlsjsConfig.liveSyncDurationCount * data.details.targetduration
370 }
371
372 this.isLive = data.details.live
373 this.dvrDuration = data.details.totalduration
374
375 this._duration = this.isLive ? Infinity : data.details.totalduration
376
377 // Increase network error recovery for lives since they can be broken (server restart, stream interruption etc)
378 if (this.isLive) this.maxNetworkErrorRecovery = 300
379 })
380
381 this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
382 // Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
383 // Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata
384 this.tech.trigger('loadedmetadata')
385 })
386
387 this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
388 const resolutionId = this.hls.autoLevelEnabled
389 ? -1
390 : data.level
391
392 const autoResolutionChosenId = this.hls.autoLevelEnabled
393 ? data.level
394 : -1
395
396 this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true })
397 })
398
399 this.hls.attachMedia(this.videoElement)
400
401 this.hls.loadSource(this.source.src)
402 }
403
404 private initialize () {
405 this._initHlsjs()
406 }
407 }
408
409 export {
410 Html5Hlsjs,
411 registerSourceHandler,
412 registerConfigPlugin
413 }