aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/assets/player
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-02-11 14:09:23 +0100
committerChocobozzz <me@florianbigard.com>2019-02-11 14:09:23 +0100
commitb718fd22374d64534bcfe69932cf562894abed6a (patch)
tree311d3c67e2a4d1f33ebdd1dc163527de9d33d0f7 /client/src/assets/player
parentadb115f5522bea4d52456a9fc5eb4140bb064476 (diff)
parent501e961199578129629cf0567033d13efced9904 (diff)
downloadPeerTube-b718fd22374d64534bcfe69932cf562894abed6a.tar.gz
PeerTube-b718fd22374d64534bcfe69932cf562894abed6a.tar.zst
PeerTube-b718fd22374d64534bcfe69932cf562894abed6a.zip
Merge branch 'develop' into pr/1285
Diffstat (limited to 'client/src/assets/player')
-rw-r--r--client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts143
-rw-r--r--client/src/assets/player/p2p-media-loader/segment-url-builder.ts28
-rw-r--r--client/src/assets/player/p2p-media-loader/segment-validator.ts63
-rw-r--r--client/src/assets/player/peertube-player-manager.ts466
-rw-r--r--client/src/assets/player/peertube-player.ts292
-rw-r--r--client/src/assets/player/peertube-plugin.ts262
-rw-r--r--client/src/assets/player/peertube-videojs-typings.ts92
-rw-r--r--client/src/assets/player/resolution-menu-button.ts88
-rw-r--r--client/src/assets/player/resolution-menu-item.ts67
-rw-r--r--client/src/assets/player/utils.ts14
-rw-r--r--client/src/assets/player/videojs-components/p2p-info-button.ts (renamed from client/src/assets/player/webtorrent-info-button.ts)25
-rw-r--r--client/src/assets/player/videojs-components/peertube-link-button.ts (renamed from client/src/assets/player/peertube-link-button.ts)4
-rw-r--r--client/src/assets/player/videojs-components/peertube-load-progress-bar.ts (renamed from client/src/assets/player/peertube-load-progress-bar.ts)4
-rw-r--r--client/src/assets/player/videojs-components/resolution-menu-button.ts109
-rw-r--r--client/src/assets/player/videojs-components/resolution-menu-item.ts83
-rw-r--r--client/src/assets/player/videojs-components/settings-menu-button.ts (renamed from client/src/assets/player/settings-menu-button.ts)4
-rw-r--r--client/src/assets/player/videojs-components/settings-menu-item.ts (renamed from client/src/assets/player/settings-menu-item.ts)34
-rw-r--r--client/src/assets/player/videojs-components/theater-button.ts (renamed from client/src/assets/player/theater-button.ts)4
-rw-r--r--client/src/assets/player/webtorrent/peertube-chunk-store.ts (renamed from client/src/assets/player/peertube-chunk-store.ts)0
-rw-r--r--client/src/assets/player/webtorrent/video-renderer.ts (renamed from client/src/assets/player/video-renderer.ts)0
-rw-r--r--client/src/assets/player/webtorrent/webtorrent-plugin.ts (renamed from client/src/assets/player/peertube-videojs-plugin.ts)306
21 files changed, 1396 insertions, 692 deletions
diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
new file mode 100644
index 000000000..022a9c16f
--- /dev/null
+++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
@@ -0,0 +1,143 @@
1// FIXME: something weird with our path definition in tsconfig and typings
2// @ts-ignore
3import * as videojs from 'video.js'
4import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings'
5import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
6import { Events } from 'p2p-media-loader-core'
7
8// videojs-hlsjs-plugin needs videojs in window
9window['videojs'] = videojs
10require('@streamroot/videojs-hlsjs-plugin')
11
12const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
13class P2pMediaLoaderPlugin extends Plugin {
14
15 private readonly CONSTANTS = {
16 INFO_SCHEDULER: 1000 // Don't change this
17 }
18 private readonly options: P2PMediaLoaderPluginOptions
19
20 private hlsjs: any // Don't type hlsjs to not bundle the module
21 private p2pEngine: Engine
22 private statsP2PBytes = {
23 pendingDownload: [] as number[],
24 pendingUpload: [] as number[],
25 numPeers: 0,
26 totalDownload: 0,
27 totalUpload: 0
28 }
29 private statsHTTPBytes = {
30 pendingDownload: [] as number[],
31 pendingUpload: [] as number[],
32 totalDownload: 0,
33 totalUpload: 0
34 }
35
36 private networkInfoInterval: any
37
38 constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) {
39 super(player, options)
40
41 this.options = options
42
43 videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
44 this.hlsjs = hlsjs
45 })
46
47 initVideoJsContribHlsJsPlayer(player)
48
49 player.src({
50 type: options.type,
51 src: options.src
52 })
53
54 player.on('play', () => {
55 player.addClass('vjs-has-big-play-button-clicked')
56 })
57
58 player.ready(() => this.initialize())
59 }
60
61 dispose () {
62 if (this.hlsjs) this.hlsjs.destroy()
63 if (this.p2pEngine) this.p2pEngine.destroy()
64
65 clearInterval(this.networkInfoInterval)
66 }
67
68 private initialize () {
69 initHlsJsPlayer(this.hlsjs)
70
71 const tech = this.player.tech_
72 this.p2pEngine = tech.options_.hlsjsConfig.loader.getEngine()
73
74 // Avoid using constants to not import hls.hs
75 // https://github.com/video-dev/hls.js/blob/master/src/events.js#L37
76 this.hlsjs.on('hlsLevelSwitching', (_: any, data: any) => {
77 this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height })
78 })
79
80 this.p2pEngine.on(Events.SegmentError, (segment, err) => {
81 console.error('Segment error.', segment, err)
82 })
83
84 this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length
85
86 this.runStats()
87 }
88
89 private runStats () {
90 this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => {
91 const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
92
93 elem.pendingDownload.push(size)
94 elem.totalDownload += size
95 })
96
97 this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => {
98 const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
99
100 elem.pendingUpload.push(size)
101 elem.totalUpload += size
102 })
103
104 this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++)
105 this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--)
106
107 this.networkInfoInterval = setInterval(() => {
108 const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload)
109 const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload)
110
111 const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload)
112 const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload)
113
114 this.statsP2PBytes.pendingDownload = []
115 this.statsP2PBytes.pendingUpload = []
116 this.statsHTTPBytes.pendingDownload = []
117 this.statsHTTPBytes.pendingUpload = []
118
119 return this.player.trigger('p2pInfo', {
120 http: {
121 downloadSpeed: httpDownloadSpeed,
122 uploadSpeed: httpUploadSpeed,
123 downloaded: this.statsHTTPBytes.totalDownload,
124 uploaded: this.statsHTTPBytes.totalUpload
125 },
126 p2p: {
127 downloadSpeed: p2pDownloadSpeed,
128 uploadSpeed: p2pUploadSpeed,
129 numPeers: this.statsP2PBytes.numPeers,
130 downloaded: this.statsP2PBytes.totalDownload,
131 uploaded: this.statsP2PBytes.totalUpload
132 }
133 } as PlayerNetworkInfo)
134 }, this.CONSTANTS.INFO_SCHEDULER)
135 }
136
137 private arraySum (data: number[]) {
138 return data.reduce((a: number, b: number) => a + b, 0)
139 }
140}
141
142videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
143export { P2pMediaLoaderPlugin }
diff --git a/client/src/assets/player/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts
new file mode 100644
index 000000000..32e7ce4f2
--- /dev/null
+++ b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts
@@ -0,0 +1,28 @@
1import { basename } from 'path'
2import { Segment } from 'p2p-media-loader-core'
3
4function segmentUrlBuilderFactory (baseUrls: string[]) {
5 return function segmentBuilder (segment: Segment) {
6 const max = baseUrls.length + 1
7 const i = getRandomInt(max)
8
9 if (i === max - 1) return segment.url
10
11 let newBaseUrl = baseUrls[i]
12 let middlePart = newBaseUrl.endsWith('/') ? '' : '/'
13
14 return newBaseUrl + middlePart + basename(segment.url)
15 }
16}
17
18// ---------------------------------------------------------------------------
19
20export {
21 segmentUrlBuilderFactory
22}
23
24// ---------------------------------------------------------------------------
25
26function getRandomInt (max: number) {
27 return Math.floor(Math.random() * Math.floor(max))
28}
diff --git a/client/src/assets/player/p2p-media-loader/segment-validator.ts b/client/src/assets/player/p2p-media-loader/segment-validator.ts
new file mode 100644
index 000000000..72c32f9e0
--- /dev/null
+++ b/client/src/assets/player/p2p-media-loader/segment-validator.ts
@@ -0,0 +1,63 @@
1import { Segment } from 'p2p-media-loader-core'
2import { basename } from 'path'
3
4function segmentValidatorFactory (segmentsSha256Url: string) {
5 const segmentsJSON = fetchSha256Segments(segmentsSha256Url)
6 const regex = /bytes=(\d+)-(\d+)/
7
8 return async function segmentValidator (segment: Segment) {
9 const filename = basename(segment.url)
10 const captured = regex.exec(segment.range)
11
12 const range = captured[1] + '-' + captured[2]
13
14 const hashShouldBe = (await segmentsJSON)[filename][range]
15 if (hashShouldBe === undefined) {
16 throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
17 }
18
19 const calculatedSha = bufferToEx(await sha256(segment.data))
20 if (calculatedSha !== hashShouldBe) {
21 throw new Error(
22 `Hashes does not correspond for segment ${filename}/${range}` +
23 `(expected: ${hashShouldBe} instead of ${calculatedSha})`
24 )
25 }
26 }
27}
28
29// ---------------------------------------------------------------------------
30
31export {
32 segmentValidatorFactory
33}
34
35// ---------------------------------------------------------------------------
36
37function fetchSha256Segments (url: string) {
38 return fetch(url)
39 .then(res => res.json())
40 .catch(err => {
41 console.error('Cannot get sha256 segments', err)
42 return {}
43 })
44}
45
46function sha256 (data?: ArrayBuffer) {
47 if (!data) return undefined
48
49 return window.crypto.subtle.digest('SHA-256', data)
50}
51
52// Thanks: https://stackoverflow.com/a/53307879
53function bufferToEx (buffer?: ArrayBuffer) {
54 if (!buffer) return ''
55
56 let s = ''
57 const h = '0123456789abcdef'
58 const o = new Uint8Array(buffer)
59
60 o.forEach((v: any) => s += h[ v >> 4 ] + h[ v & 15 ])
61
62 return s
63}
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
new file mode 100644
index 000000000..0ba9bcb11
--- /dev/null
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -0,0 +1,466 @@
1import { VideoFile } from '../../../../shared/models/videos'
2// @ts-ignore
3import * as videojs from 'video.js'
4import 'videojs-hotkeys'
5import 'videojs-dock'
6import 'videojs-contextmenu-ui'
7import 'videojs-contrib-quality-levels'
8import './peertube-plugin'
9import './videojs-components/peertube-link-button'
10import './videojs-components/resolution-menu-button'
11import './videojs-components/settings-menu-button'
12import './videojs-components/p2p-info-button'
13import './videojs-components/peertube-load-progress-bar'
14import './videojs-components/theater-button'
15import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings'
16import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils'
17import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
18import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
19import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
20
21// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
22videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
23// Change Captions to Subtitles/CC
24videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC'
25// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
26videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
27
28export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
29
30export type WebtorrentOptions = {
31 videoFiles: VideoFile[]
32}
33
34export type P2PMediaLoaderOptions = {
35 playlistUrl: string
36 segmentsSha256Url: string
37 trackerAnnounce: string[]
38 redundancyBaseUrls: string[]
39 videoFiles: VideoFile[]
40}
41
42export type CommonOptions = {
43 playerElement: HTMLVideoElement
44 onPlayerElementChange: (element: HTMLVideoElement) => void
45
46 autoplay: boolean
47 videoDuration: number
48 enableHotkeys: boolean
49 inactivityTimeout: number
50 poster: string
51 startTime: number | string
52
53 theaterMode: boolean
54 captions: boolean
55 peertubeLink: boolean
56
57 videoViewUrl: string
58 embedUrl: string
59
60 language?: string
61 controls?: boolean
62 muted?: boolean
63 loop?: boolean
64 subtitle?: string
65
66 videoCaptions: VideoJSCaption[]
67
68 userWatching?: UserWatching
69
70 serverUrl: string
71}
72
73export type PeertubePlayerManagerOptions = {
74 common: CommonOptions,
75 webtorrent: WebtorrentOptions,
76 p2pMediaLoader?: P2PMediaLoaderOptions
77}
78
79export class PeertubePlayerManager {
80
81 private static videojsLocaleCache: { [ path: string ]: any } = {}
82 private static playerElementClassName: string
83
84 static getServerTranslations (serverUrl: string, locale: string) {
85 const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
86 // It is the default locale, nothing to translate
87 if (!path) return Promise.resolve(undefined)
88
89 return fetch(path + '/server.json')
90 .then(res => res.json())
91 .catch(err => {
92 console.error('Cannot get server translations', err)
93 return undefined
94 })
95 }
96
97 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
98 let p2pMediaLoader: any
99
100 this.playerElementClassName = options.common.playerElement.className
101
102 if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
103 if (mode === 'p2p-media-loader') {
104 [ p2pMediaLoader ] = await Promise.all([
105 import('p2p-media-loader-hlsjs'),
106 import('./p2p-media-loader/p2p-media-loader-plugin')
107 ])
108 }
109
110 const videojsOptions = this.getVideojsOptions(mode, options, p2pMediaLoader)
111
112 await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language)
113
114 const self = this
115 return new Promise(res => {
116 videojs(options.common.playerElement, videojsOptions, function (this: any) {
117 const player = this
118
119 player.tech_.on('error', () => {
120 // Fallback to webtorrent?
121 if (mode === 'p2p-media-loader') {
122 self.fallbackToWebTorrent(player, options)
123 }
124 })
125
126 self.addContextMenu(mode, player, options.common.embedUrl)
127
128 return res(player)
129 })
130 })
131 }
132
133 private static async fallbackToWebTorrent (player: any, options: PeertubePlayerManagerOptions) {
134 const newVideoElement = document.createElement('video')
135 newVideoElement.className = this.playerElementClassName
136
137 // VideoJS wraps our video element inside a div
138 const currentParentPlayerElement = options.common.playerElement.parentNode
139 currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
140
141 options.common.playerElement = newVideoElement
142 options.common.onPlayerElementChange(newVideoElement)
143
144 player.dispose()
145
146 await import('./webtorrent/webtorrent-plugin')
147
148 const mode = 'webtorrent'
149 const videojsOptions = this.getVideojsOptions(mode, options)
150
151 const self = this
152 videojs(newVideoElement, videojsOptions, function (this: any) {
153 const player = this
154
155 self.addContextMenu(mode, player, options.common.embedUrl)
156 })
157 }
158
159 private static loadLocaleInVideoJS (serverUrl: string, locale: string) {
160 const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
161 // It is the default locale, nothing to translate
162 if (!path) return Promise.resolve(undefined)
163
164 let p: Promise<any>
165
166 if (PeertubePlayerManager.videojsLocaleCache[path]) {
167 p = Promise.resolve(PeertubePlayerManager.videojsLocaleCache[path])
168 } else {
169 p = fetch(path + '/player.json')
170 .then(res => res.json())
171 .then(json => {
172 PeertubePlayerManager.videojsLocaleCache[path] = json
173 return json
174 })
175 .catch(err => {
176 console.error('Cannot get player translations', err)
177 return undefined
178 })
179 }
180
181 const completeLocale = getCompleteLocale(locale)
182 return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
183 }
184
185 private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions, p2pMediaLoaderModule?: any) {
186 const commonOptions = options.common
187 const webtorrentOptions = options.webtorrent
188 const p2pMediaLoaderOptions = options.p2pMediaLoader
189
190 let autoplay = options.common.autoplay
191 let html5 = {}
192
193 const plugins: VideoJSPluginOptions = {
194 peertube: {
195 mode,
196 autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
197 videoViewUrl: commonOptions.videoViewUrl,
198 videoDuration: commonOptions.videoDuration,
199 startTime: commonOptions.startTime,
200 userWatching: commonOptions.userWatching,
201 subtitle: commonOptions.subtitle,
202 videoCaptions: commonOptions.videoCaptions
203 }
204 }
205
206 if (mode === 'p2p-media-loader') {
207 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
208 redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls,
209 type: 'application/x-mpegURL',
210 src: p2pMediaLoaderOptions.playlistUrl
211 }
212
213 const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
214 .filter(t => t.startsWith('ws'))
215
216 const p2pMediaLoaderConfig = {
217 loader: {
218 trackerAnnounce,
219 segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url),
220 rtcConfig: getRtcConfig(),
221 requiredSegmentsPriority: 5,
222 segmentUrlBuilder: segmentUrlBuilderFactory(options.p2pMediaLoader.redundancyBaseUrls)
223 },
224 segments: {
225 swarmId: p2pMediaLoaderOptions.playlistUrl
226 }
227 }
228 const streamrootHls = {
229 levelLabelHandler: (level: { height: number, width: number }) => {
230 const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === level.height)
231
232 let label = file.resolution.label
233 if (file.fps >= 50) label += file.fps
234
235 return label
236 },
237 html5: {
238 hlsjsConfig: {
239 liveSyncDurationCount: 7,
240 loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
241 }
242 }
243 }
244
245 Object.assign(plugins, { p2pMediaLoader, streamrootHls })
246 html5 = streamrootHls.html5
247 }
248
249 if (mode === 'webtorrent') {
250 const webtorrent = {
251 autoplay,
252 videoDuration: commonOptions.videoDuration,
253 playerElement: commonOptions.playerElement,
254 videoFiles: webtorrentOptions.videoFiles
255 }
256 Object.assign(plugins, { webtorrent })
257
258 // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
259 autoplay = false
260 }
261
262 const videojsOptions = {
263 html5,
264
265 // We don't use text track settings for now
266 textTrackSettings: false,
267 controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
268 loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
269
270 muted: commonOptions.muted !== undefined
271 ? commonOptions.muted
272 : undefined, // Undefined so the player knows it has to check the local storage
273
274 poster: commonOptions.poster,
275 autoplay: autoplay === true ? 'any' : autoplay, // Use 'any' instead of true to get notifier by videojs if autoplay fails
276 inactivityTimeout: commonOptions.inactivityTimeout,
277 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
278 plugins,
279 controlBar: {
280 children: this.getControlBarChildren(mode, {
281 captions: commonOptions.captions,
282 peertubeLink: commonOptions.peertubeLink,
283 theaterMode: commonOptions.theaterMode
284 })
285 }
286 }
287
288 if (commonOptions.enableHotkeys === true) {
289 Object.assign(videojsOptions.plugins, {
290 hotkeys: {
291 enableVolumeScroll: false,
292 enableModifiersForNumbers: false,
293
294 fullscreenKey: function (event: KeyboardEvent) {
295 // fullscreen with the f key or Ctrl+Enter
296 return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
297 },
298
299 seekStep: function (event: KeyboardEvent) {
300 // mimic VLC seek behavior, and default to 5 (original value is 5).
301 if (event.ctrlKey && event.altKey) {
302 return 5 * 60
303 } else if (event.ctrlKey) {
304 return 60
305 } else if (event.altKey) {
306 return 10
307 } else {
308 return 5
309 }
310 },
311
312 customKeys: {
313 increasePlaybackRateKey: {
314 key: function (event: KeyboardEvent) {
315 return event.key === '>'
316 },
317 handler: function (player: videojs.Player) {
318 player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
319 }
320 },
321 decreasePlaybackRateKey: {
322 key: function (event: KeyboardEvent) {
323 return event.key === '<'
324 },
325 handler: function (player: videojs.Player) {
326 player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
327 }
328 },
329 frameByFrame: {
330 key: function (event: KeyboardEvent) {
331 return event.key === '.'
332 },
333 handler: function (player: videojs.Player) {
334 player.pause()
335 // Calculate movement distance (assuming 30 fps)
336 const dist = 1 / 30
337 player.currentTime(player.currentTime() + dist)
338 }
339 }
340 }
341 }
342 })
343 }
344
345 if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
346 Object.assign(videojsOptions, { language: commonOptions.language })
347 }
348
349 return videojsOptions
350 }
351
352 private static getControlBarChildren (mode: PlayerMode, options: {
353 peertubeLink: boolean
354 theaterMode: boolean,
355 captions: boolean
356 }) {
357 const settingEntries = []
358 const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
359
360 // Keep an order
361 settingEntries.push('playbackRateMenuButton')
362 if (options.captions === true) settingEntries.push('captionsButton')
363 settingEntries.push('resolutionMenuButton')
364
365 const children = {
366 'playToggle': {},
367 'currentTimeDisplay': {},
368 'timeDivider': {},
369 'durationDisplay': {},
370 'liveDisplay': {},
371
372 'flexibleWidthSpacer': {},
373 'progressControl': {
374 children: {
375 'seekBar': {
376 children: {
377 [loadProgressBar]: {},
378 'mouseTimeDisplay': {},
379 'playProgressBar': {}
380 }
381 }
382 }
383 },
384
385 'p2PInfoButton': {},
386
387 'muteToggle': {},
388 'volumeControl': {},
389
390 'settingsButton': {
391 setup: {
392 maxHeightOffset: 40
393 },
394 entries: settingEntries
395 }
396 }
397
398 if (options.peertubeLink === true) {
399 Object.assign(children, {
400 'peerTubeLinkButton': {}
401 })
402 }
403
404 if (options.theaterMode === true) {
405 Object.assign(children, {
406 'theaterButton': {}
407 })
408 }
409
410 Object.assign(children, {
411 'fullscreenToggle': {}
412 })
413
414 return children
415 }
416
417 private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) {
418 const content = [
419 {
420 label: player.localize('Copy the video URL'),
421 listener: function () {
422 copyToClipboard(buildVideoLink())
423 }
424 },
425 {
426 label: player.localize('Copy the video URL at the current time'),
427 listener: function () {
428 const player = this as videojs.Player
429 copyToClipboard(buildVideoLink(player.currentTime()))
430 }
431 },
432 {
433 label: player.localize('Copy embed code'),
434 listener: () => {
435 copyToClipboard(buildVideoEmbed(videoEmbedUrl))
436 }
437 }
438 ]
439
440 if (mode === 'webtorrent') {
441 content.push({
442 label: player.localize('Copy magnet URI'),
443 listener: function () {
444 const player = this as videojs.Player
445 copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri)
446 }
447 })
448 }
449
450 player.contextmenuUI({ content })
451 }
452
453 private static getLocalePath (serverUrl: string, locale: string) {
454 const completeLocale = getCompleteLocale(locale)
455
456 if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
457
458 return serverUrl + '/client/locales/' + completeLocale
459 }
460}
461
462// ############################################################################
463
464export {
465 videojs
466}
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
deleted file mode 100644
index e0e063838..000000000
--- a/client/src/assets/player/peertube-player.ts
+++ /dev/null
@@ -1,292 +0,0 @@
1import { VideoFile } from '../../../../shared/models/videos'
2
3import 'videojs-hotkeys'
4import 'videojs-dock'
5import 'videojs-contextmenu-ui'
6import './peertube-link-button'
7import './resolution-menu-button'
8import './settings-menu-button'
9import './webtorrent-info-button'
10import './peertube-videojs-plugin'
11import './peertube-load-progress-bar'
12import './theater-button'
13import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
14import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
15import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
16
17// FIXME: something weird with our path definition in tsconfig and typings
18// @ts-ignore
19import { Player } from 'video.js'
20
21// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
22videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
23// Change Captions to Subtitles/CC
24videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC'
25// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
26videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
27
28function getVideojsOptions (options: {
29 autoplay: boolean
30 playerElement: HTMLVideoElement
31 videoViewUrl: string
32 videoDuration: number
33 videoFiles: VideoFile[]
34 enableHotkeys: boolean
35 inactivityTimeout: number
36 peertubeLink: boolean
37 poster: string
38 startTime: number | string
39 theaterMode: boolean
40 videoCaptions: VideoJSCaption[]
41
42 language?: string
43 controls?: boolean
44 muted?: boolean
45 loop?: boolean
46 subtitle?: string
47
48 userWatching?: UserWatching
49}) {
50 const videojsOptions = {
51 // We don't use text track settings for now
52 textTrackSettings: false,
53 controls: options.controls !== undefined ? options.controls : true,
54 loop: options.loop !== undefined ? options.loop : false,
55
56 muted: options.muted !== undefined ? options.muted : undefined, // Undefined so the player knows it has to check the local storage
57
58 poster: options.poster,
59 autoplay: false,
60 inactivityTimeout: options.inactivityTimeout,
61 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
62 plugins: {
63 peertube: {
64 autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
65 videoCaptions: options.videoCaptions,
66 videoFiles: options.videoFiles,
67 playerElement: options.playerElement,
68 videoViewUrl: options.videoViewUrl,
69 videoDuration: options.videoDuration,
70 startTime: options.startTime,
71 userWatching: options.userWatching,
72 subtitle: options.subtitle
73 }
74 },
75 controlBar: {
76 children: getControlBarChildren(options)
77 }
78 }
79
80 if (options.enableHotkeys === true) {
81 Object.assign(videojsOptions.plugins, {
82 hotkeys: {
83 enableVolumeScroll: false,
84 enableModifiersForNumbers: false,
85
86 fullscreenKey: function (event: KeyboardEvent) {
87 // fullscreen with the f key or Ctrl+Enter
88 return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
89 },
90
91 seekStep: function (event: KeyboardEvent) {
92 // mimic VLC seek behavior, and default to 5 (original value is 5).
93 if (event.ctrlKey && event.altKey) {
94 return 5 * 60
95 } else if (event.ctrlKey) {
96 return 60
97 } else if (event.altKey) {
98 return 10
99 } else {
100 return 5
101 }
102 },
103
104 customKeys: {
105 increasePlaybackRateKey: {
106 key: function (event: KeyboardEvent) {
107 return event.key === '>'
108 },
109 handler: function (player: Player) {
110 player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
111 }
112 },
113 decreasePlaybackRateKey: {
114 key: function (event: KeyboardEvent) {
115 return event.key === '<'
116 },
117 handler: function (player: Player) {
118 player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
119 }
120 },
121 frameByFrame: {
122 key: function (event: KeyboardEvent) {
123 return event.key === '.'
124 },
125 handler: function (player: Player) {
126 player.pause()
127 // Calculate movement distance (assuming 30 fps)
128 const dist = 1 / 30
129 player.currentTime(player.currentTime() + dist)
130 }
131 }
132 }
133 }
134 })
135 }
136
137 if (options.language && !isDefaultLocale(options.language)) {
138 Object.assign(videojsOptions, { language: options.language })
139 }
140
141 return videojsOptions
142}
143
144function getControlBarChildren (options: {
145 peertubeLink: boolean
146 theaterMode: boolean,
147 videoCaptions: VideoJSCaption[]
148}) {
149 const settingEntries = []
150
151 // Keep an order
152 settingEntries.push('playbackRateMenuButton')
153 if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton')
154 settingEntries.push('resolutionMenuButton')
155
156 const children = {
157 'playToggle': {},
158 'currentTimeDisplay': {},
159 'timeDivider': {},
160 'durationDisplay': {},
161 'liveDisplay': {},
162
163 'flexibleWidthSpacer': {},
164 'progressControl': {
165 children: {
166 'seekBar': {
167 children: {
168 'peerTubeLoadProgressBar': {},
169 'mouseTimeDisplay': {},
170 'playProgressBar': {}
171 }
172 }
173 }
174 },
175
176 'webTorrentButton': {},
177
178 'muteToggle': {},
179 'volumeControl': {},
180
181 'settingsButton': {
182 setup: {
183 maxHeightOffset: 40
184 },
185 entries: settingEntries
186 }
187 }
188
189 if (options.peertubeLink === true) {
190 Object.assign(children, {
191 'peerTubeLinkButton': {}
192 })
193 }
194
195 if (options.theaterMode === true) {
196 Object.assign(children, {
197 'theaterButton': {}
198 })
199 }
200
201 Object.assign(children, {
202 'fullscreenToggle': {}
203 })
204
205 return children
206}
207
208function addContextMenu (player: any, videoEmbedUrl: string) {
209 player.contextmenuUI({
210 content: [
211 {
212 label: player.localize('Copy the video URL'),
213 listener: function () {
214 copyToClipboard(buildVideoLink())
215 }
216 },
217 {
218 label: player.localize('Copy the video URL at the current time'),
219 listener: function () {
220 const player = this as Player
221 copyToClipboard(buildVideoLink(player.currentTime()))
222 }
223 },
224 {
225 label: player.localize('Copy embed code'),
226 listener: () => {
227 copyToClipboard(buildVideoEmbed(videoEmbedUrl))
228 }
229 },
230 {
231 label: player.localize('Copy magnet URI'),
232 listener: function () {
233 const player = this as Player
234 copyToClipboard(player.peertube().getCurrentVideoFile().magnetUri)
235 }
236 }
237 ]
238 })
239}
240
241function loadLocaleInVideoJS (serverUrl: string, videojs: any, locale: string) {
242 const path = getLocalePath(serverUrl, locale)
243 // It is the default locale, nothing to translate
244 if (!path) return Promise.resolve(undefined)
245
246 let p: Promise<any>
247
248 if (loadLocaleInVideoJS.cache[path]) {
249 p = Promise.resolve(loadLocaleInVideoJS.cache[path])
250 } else {
251 p = fetch(path + '/player.json')
252 .then(res => res.json())
253 .then(json => {
254 loadLocaleInVideoJS.cache[path] = json
255 return json
256 })
257 }
258
259 const completeLocale = getCompleteLocale(locale)
260 return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json))
261}
262namespace loadLocaleInVideoJS {
263 export const cache: { [ path: string ]: any } = {}
264}
265
266function getServerTranslations (serverUrl: string, locale: string) {
267 const path = getLocalePath(serverUrl, locale)
268 // It is the default locale, nothing to translate
269 if (!path) return Promise.resolve(undefined)
270
271 return fetch(path + '/server.json')
272 .then(res => res.json())
273}
274
275// ############################################################################
276
277export {
278 getServerTranslations,
279 loadLocaleInVideoJS,
280 getVideojsOptions,
281 addContextMenu
282}
283
284// ############################################################################
285
286function getLocalePath (serverUrl: string, locale: string) {
287 const completeLocale = getCompleteLocale(locale)
288
289 if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined
290
291 return serverUrl + '/client/locales/' + completeLocale
292}
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts
new file mode 100644
index 000000000..7ea4a06d4
--- /dev/null
+++ b/client/src/assets/player/peertube-plugin.ts
@@ -0,0 +1,262 @@
1// FIXME: something weird with our path definition in tsconfig and typings
2// @ts-ignore
3import * as videojs from 'video.js'
4import './videojs-components/settings-menu-button'
5import {
6 PeerTubePluginOptions,
7 ResolutionUpdateData,
8 UserWatching,
9 VideoJSCaption,
10 VideoJSComponentInterface,
11 videojsUntyped
12} from './peertube-videojs-typings'
13import { isMobile, timeToInt } from './utils'
14import {
15 getStoredLastSubtitle,
16 getStoredMute,
17 getStoredVolume,
18 saveLastSubtitle,
19 saveMuteInStore,
20 saveVolumeInStore
21} from './peertube-player-local-storage'
22
23const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
24class 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
261videojs.registerPlugin('peertube', PeerTubePlugin)
262export { PeerTubePlugin }
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts
index 634c7fdc9..79a5a6c4d 100644
--- a/client/src/assets/player/peertube-videojs-typings.ts
+++ b/client/src/assets/player/peertube-videojs-typings.ts
@@ -3,11 +3,16 @@
3import * as videojs from 'video.js' 3import * as videojs from 'video.js'
4 4
5import { VideoFile } from '../../../../shared/models/videos/video.model' 5import { VideoFile } from '../../../../shared/models/videos/video.model'
6import { PeerTubePlugin } from './peertube-videojs-plugin' 6import { PeerTubePlugin } from './peertube-plugin'
7import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
8import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
9import { PlayerMode } from './peertube-player-manager'
7 10
8declare namespace videojs { 11declare namespace videojs {
9 interface Player { 12 interface Player {
10 peertube (): PeerTubePlugin 13 peertube (): PeerTubePlugin
14 webtorrent (): WebTorrentPlugin
15 p2pMediaLoader (): P2pMediaLoaderPlugin
11 } 16 }
12} 17}
13 18
@@ -30,26 +35,95 @@ type UserWatching = {
30 authorizationHeader: string 35 authorizationHeader: string
31} 36}
32 37
33type PeertubePluginOptions = { 38type PeerTubePluginOptions = {
34 videoFiles: VideoFile[] 39 mode: PlayerMode
35 playerElement: HTMLVideoElement 40
41 autoplay: boolean
36 videoViewUrl: string 42 videoViewUrl: string
37 videoDuration: number 43 videoDuration: number
38 startTime: number | string 44 startTime: number | string
39 autoplay: boolean,
40 videoCaptions: VideoJSCaption[]
41 45
42 subtitle?: string
43 userWatching?: UserWatching 46 userWatching?: UserWatching
47 subtitle?: string
48
49 videoCaptions: VideoJSCaption[]
50}
51
52type WebtorrentPluginOptions = {
53 playerElement: HTMLVideoElement
54
55 autoplay: boolean
56 videoDuration: number
57
58 videoFiles: VideoFile[]
59}
60
61type P2PMediaLoaderPluginOptions = {
62 redundancyBaseUrls: string[]
63 type: string
64 src: string
65}
66
67type VideoJSPluginOptions = {
68 peertube: PeerTubePluginOptions
69
70 webtorrent?: WebtorrentPluginOptions
71
72 p2pMediaLoader?: P2PMediaLoaderPluginOptions
44} 73}
45 74
46// videojs typings don't have some method we need 75// videojs typings don't have some method we need
47const videojsUntyped = videojs as any 76const videojsUntyped = videojs as any
48 77
78type LoadedQualityData = {
79 qualitySwitchCallback: Function,
80 qualityData: {
81 video: {
82 id: number
83 label: string
84 selected: boolean
85 }[]
86 }
87}
88
89type ResolutionUpdateData = {
90 auto: boolean,
91 resolutionId: number
92 id?: number
93}
94
95type AutoResolutionUpdateData = {
96 possible: boolean
97}
98
99type PlayerNetworkInfo = {
100 http: {
101 downloadSpeed: number
102 uploadSpeed: number
103 downloaded: number
104 uploaded: number
105 }
106
107 p2p: {
108 downloadSpeed: number
109 uploadSpeed: number
110 downloaded: number
111 uploaded: number
112 numPeers: number
113 }
114}
115
49export { 116export {
117 PlayerNetworkInfo,
118 ResolutionUpdateData,
119 AutoResolutionUpdateData,
50 VideoJSComponentInterface, 120 VideoJSComponentInterface,
51 PeertubePluginOptions,
52 videojsUntyped, 121 videojsUntyped,
53 VideoJSCaption, 122 VideoJSCaption,
54 UserWatching 123 UserWatching,
124 PeerTubePluginOptions,
125 WebtorrentPluginOptions,
126 P2PMediaLoaderPluginOptions,
127 VideoJSPluginOptions,
128 LoadedQualityData
55} 129}
diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts
deleted file mode 100644
index a3c1108ca..000000000
--- a/client/src/assets/player/resolution-menu-button.ts
+++ /dev/null
@@ -1,88 +0,0 @@
1// FIXME: something weird with our path definition in tsconfig and typings
2// @ts-ignore
3import { Player } from 'video.js'
4
5import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
6import { ResolutionMenuItem } from './resolution-menu-item'
7
8const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
9const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton')
10class ResolutionMenuButton extends MenuButton {
11 label: HTMLElement
12
13 constructor (player: Player, options: any) {
14 super(player, options)
15 this.player = player
16
17 player.peertube().on('videoFileUpdate', () => this.updateLabel())
18 player.peertube().on('autoResolutionUpdate', () => this.updateLabel())
19 }
20
21 createEl () {
22 const el = super.createEl()
23
24 this.labelEl_ = videojsUntyped.dom.createEl('div', {
25 className: 'vjs-resolution-value',
26 innerHTML: this.buildLabelHTML()
27 })
28
29 el.appendChild(this.labelEl_)
30
31 return el
32 }
33
34 updateARIAAttributes () {
35 this.el().setAttribute('aria-label', 'Quality')
36 }
37
38 createMenu () {
39 const menu = new Menu(this.player_)
40 for (const videoFile of this.player_.peertube().videoFiles) {
41 let label = videoFile.resolution.label
42 if (videoFile.fps && videoFile.fps >= 50) {
43 label += videoFile.fps
44 }
45
46 menu.addChild(new ResolutionMenuItem(
47 this.player_,
48 {
49 id: videoFile.resolution.id,
50 label,
51 src: videoFile.magnetUri
52 })
53 )
54 }
55
56 menu.addChild(new ResolutionMenuItem(
57 this.player_,
58 {
59 id: -1,
60 label: this.player_.localize('Auto'),
61 src: null
62 }
63 ))
64
65 return menu
66 }
67
68 updateLabel () {
69 if (!this.labelEl_) return
70
71 this.labelEl_.innerHTML = this.buildLabelHTML()
72 }
73
74 buildCSSClass () {
75 return super.buildCSSClass() + ' vjs-resolution-button'
76 }
77
78 buildWrapperCSSClass () {
79 return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
80 }
81
82 private buildLabelHTML () {
83 return this.player_.peertube().getCurrentResolutionLabel()
84 }
85}
86ResolutionMenuButton.prototype.controlText_ = 'Quality'
87
88MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts
deleted file mode 100644
index b54fd91ef..000000000
--- a/client/src/assets/player/resolution-menu-item.ts
+++ /dev/null
@@ -1,67 +0,0 @@
1// FIXME: something weird with our path definition in tsconfig and typings
2// @ts-ignore
3import { Player } from 'video.js'
4
5import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
6
7const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
8class ResolutionMenuItem extends MenuItem {
9
10 constructor (player: Player, options: any) {
11 const currentResolutionId = player.peertube().getCurrentResolutionId()
12 options.selectable = true
13 options.selected = options.id === currentResolutionId
14
15 super(player, options)
16
17 this.label = options.label
18 this.id = options.id
19
20 player.peertube().on('videoFileUpdate', () => this.updateSelection())
21 player.peertube().on('autoResolutionUpdate', () => this.updateSelection())
22 }
23
24 handleClick (event: any) {
25 if (this.id === -1 && this.player_.peertube().isAutoResolutionForbidden()) return
26
27 super.handleClick(event)
28
29 // Auto resolution
30 if (this.id === -1) {
31 this.player_.peertube().enableAutoResolution()
32 return
33 }
34
35 this.player_.peertube().disableAutoResolution()
36 this.player_.peertube().updateResolution(this.id)
37 }
38
39 updateSelection () {
40 // Check if auto resolution is forbidden or not
41 if (this.id === -1) {
42 if (this.player_.peertube().isAutoResolutionForbidden()) {
43 this.addClass('disabled')
44 } else {
45 this.removeClass('disabled')
46 }
47 }
48
49 if (this.player_.peertube().isAutoResolutionOn()) {
50 this.selected(this.id === -1)
51 return
52 }
53
54 this.selected(this.player_.peertube().getCurrentResolutionId() === this.id)
55 }
56
57 getLabel () {
58 if (this.id === -1) {
59 return this.label + ' <small>' + this.player_.peertube().getCurrentResolutionLabel() + '</small>'
60 }
61
62 return this.label
63 }
64}
65MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
66
67export { ResolutionMenuItem }
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts
index 8b9f34b99..8d87567c2 100644
--- a/client/src/assets/player/utils.ts
+++ b/client/src/assets/player/utils.ts
@@ -112,9 +112,23 @@ function videoFileMinByResolution (files: VideoFile[]) {
112 return min 112 return min
113} 113}
114 114
115function getRtcConfig () {
116 return {
117 iceServers: [
118 {
119 urls: 'stun:stun.stunprotocol.org'
120 },
121 {
122 urls: 'stun:stun.framasoft.org'
123 }
124 ]
125 }
126}
127
115// --------------------------------------------------------------------------- 128// ---------------------------------------------------------------------------
116 129
117export { 130export {
131 getRtcConfig,
118 toTitleCase, 132 toTitleCase,
119 timeToInt, 133 timeToInt,
120 buildVideoLink, 134 buildVideoLink,
diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts
index c3c1af951..6424787b2 100644
--- a/client/src/assets/player/webtorrent-info-button.ts
+++ b/client/src/assets/player/videojs-components/p2p-info-button.ts
@@ -1,8 +1,8 @@
1import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 1import { PlayerNetworkInfo, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
2import { bytes } from './utils' 2import { bytes } from '../utils'
3 3
4const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 4const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
5class WebtorrentInfoButton extends Button { 5class P2pInfoButton extends Button {
6 6
7 createEl () { 7 createEl () {
8 const div = videojsUntyped.dom.createEl('div', { 8 const div = videojsUntyped.dom.createEl('div', {
@@ -65,7 +65,7 @@ class WebtorrentInfoButton extends Button {
65 subDivHttp.appendChild(subDivHttpText) 65 subDivHttp.appendChild(subDivHttpText)
66 div.appendChild(subDivHttp) 66 div.appendChild(subDivHttp)
67 67
68 this.player_.peertube().on('torrentInfo', (event: any, data: any) => { 68 this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => {
69 // We are in HTTP fallback 69 // We are in HTTP fallback
70 if (!data) { 70 if (!data) {
71 subDivHttp.className = 'vjs-peertube-displayed' 71 subDivHttp.className = 'vjs-peertube-displayed'
@@ -74,11 +74,14 @@ class WebtorrentInfoButton extends Button {
74 return 74 return
75 } 75 }
76 76
77 const downloadSpeed = bytes(data.downloadSpeed) 77 const p2pStats = data.p2p
78 const uploadSpeed = bytes(data.uploadSpeed) 78 const httpStats = data.http
79 const totalDownloaded = bytes(data.downloaded) 79
80 const totalUploaded = bytes(data.uploaded) 80 const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed)
81 const numPeers = data.numPeers 81 const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed)
82 const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded)
83 const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded)
84 const numPeers = p2pStats.numPeers
82 85
83 subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + 86 subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' +
84 this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) 87 this.player_.localize('Total uploaded: ' + totalUploaded.join(' '))
@@ -90,7 +93,7 @@ class WebtorrentInfoButton extends Button {
90 uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] 93 uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ]
91 94
92 peersNumber.textContent = numPeers 95 peersNumber.textContent = numPeers
93 peersText.textContent = ' ' + this.player_.localize('peers') 96 peersText.textContent = ' ' + (numPeers > 1 ? this.player_.localize('peers') : this.player_.localize('peer'))
94 97
95 subDivHttp.className = 'vjs-peertube-hidden' 98 subDivHttp.className = 'vjs-peertube-hidden'
96 subDivWebtorrent.className = 'vjs-peertube-displayed' 99 subDivWebtorrent.className = 'vjs-peertube-displayed'
@@ -99,4 +102,4 @@ class WebtorrentInfoButton extends Button {
99 return div 102 return div
100 } 103 }
101} 104}
102Button.registerComponent('WebTorrentButton', WebtorrentInfoButton) 105Button.registerComponent('P2PInfoButton', P2pInfoButton)
diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts
index de9a49de9..fed8ea33e 100644
--- a/client/src/assets/player/peertube-link-button.ts
+++ b/client/src/assets/player/videojs-components/peertube-link-button.ts
@@ -1,5 +1,5 @@
1import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 1import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
2import { buildVideoLink } from './utils' 2import { buildVideoLink } from '../utils'
3// FIXME: something weird with our path definition in tsconfig and typings 3// FIXME: something weird with our path definition in tsconfig and typings
4// @ts-ignore 4// @ts-ignore
5import { Player } from 'video.js' 5import { Player } from 'video.js'
diff --git a/client/src/assets/player/peertube-load-progress-bar.ts b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts
index af276d1b2..9a0e3b550 100644
--- a/client/src/assets/player/peertube-load-progress-bar.ts
+++ b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts
@@ -1,4 +1,4 @@
1import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 1import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
2// FIXME: something weird with our path definition in tsconfig and typings 2// FIXME: something weird with our path definition in tsconfig and typings
3// @ts-ignore 3// @ts-ignore
4import { Player } from 'video.js' 4import { Player } from 'video.js'
@@ -27,7 +27,7 @@ class PeerTubeLoadProgressBar extends Component {
27 } 27 }
28 28
29 update () { 29 update () {
30 const torrent = this.player().peertube().getTorrent() 30 const torrent = this.player().webtorrent().getTorrent()
31 if (!torrent) return 31 if (!torrent) return
32 32
33 this.el_.style.width = (torrent.progress * 100) + '%' 33 this.el_.style.width = (torrent.progress * 100) + '%'
diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts
new file mode 100644
index 000000000..abcc16411
--- /dev/null
+++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts
@@ -0,0 +1,109 @@
1// FIXME: something weird with our path definition in tsconfig and typings
2// @ts-ignore
3import { Player } from 'video.js'
4
5import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
6import { ResolutionMenuItem } from './resolution-menu-item'
7
8const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
9const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton')
10class ResolutionMenuButton extends MenuButton {
11 label: HTMLElement
12
13 constructor (player: Player, options: any) {
14 super(player, options)
15 this.player = player
16
17 player.tech_.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data))
18
19 player.peertube().on('resolutionChange', () => setTimeout(() => this.trigger('updateLabel'), 0))
20 }
21
22 createEl () {
23 const el = super.createEl()
24
25 this.labelEl_ = videojsUntyped.dom.createEl('div', {
26 className: 'vjs-resolution-value'
27 })
28
29 el.appendChild(this.labelEl_)
30
31 return el
32 }
33
34 updateARIAAttributes () {
35 this.el().setAttribute('aria-label', 'Quality')
36 }
37
38 createMenu () {
39 return new Menu(this.player_)
40 }
41
42 buildCSSClass () {
43 return super.buildCSSClass() + ' vjs-resolution-button'
44 }
45
46 buildWrapperCSSClass () {
47 return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
48 }
49
50 private addClickListener (component: any) {
51 component.on('click', () => {
52 let children = this.menu.children()
53
54 for (const child of children) {
55 if (component !== child) {
56 child.selected(false)
57 }
58 }
59 })
60 }
61
62 private buildQualities (data: LoadedQualityData) {
63 // The automatic resolution item will need other labels
64 const labels: { [ id: number ]: string } = {}
65
66 data.qualityData.video.sort((a, b) => {
67 if (a.id > b.id) return -1
68 if (a.id === b.id) return 0
69 return 1
70 })
71
72 for (const d of data.qualityData.video) {
73 // Skip auto resolution, we'll add it ourselves
74 if (d.id === -1) continue
75
76 this.menu.addChild(new ResolutionMenuItem(
77 this.player_,
78 {
79 id: d.id,
80 label: d.label,
81 selected: d.selected,
82 callback: data.qualitySwitchCallback
83 })
84 )
85
86 labels[d.id] = d.label
87 }
88
89 this.menu.addChild(new ResolutionMenuItem(
90 this.player_,
91 {
92 id: -1,
93 label: this.player_.localize('Auto'),
94 labels,
95 callback: data.qualitySwitchCallback,
96 selected: true // By default, in auto mode
97 }
98 ))
99
100 for (const m of this.menu.children()) {
101 this.addClickListener(m)
102 }
103
104 this.trigger('menuChanged')
105 }
106}
107ResolutionMenuButton.prototype.controlText_ = 'Quality'
108
109MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts
new file mode 100644
index 000000000..6c42fefd2
--- /dev/null
+++ b/client/src/assets/player/videojs-components/resolution-menu-item.ts
@@ -0,0 +1,83 @@
1// FIXME: something weird with our path definition in tsconfig and typings
2// @ts-ignore
3import { Player } from 'video.js'
4
5import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
6
7const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
8class ResolutionMenuItem extends MenuItem {
9 private readonly id: number
10 private readonly label: string
11 // Only used for the automatic item
12 private readonly labels: { [id: number]: string }
13 private readonly callback: Function
14
15 private autoResolutionPossible: boolean
16 private currentResolutionLabel: string
17
18 constructor (player: Player, options: any) {
19 options.selectable = true
20
21 super(player, options)
22
23 this.autoResolutionPossible = true
24 this.currentResolutionLabel = ''
25
26 this.label = options.label
27 this.labels = options.labels
28 this.id = options.id
29 this.callback = options.callback
30
31 player.peertube().on('resolutionChange', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
32
33 // We only want to disable the "Auto" item
34 if (this.id === -1) {
35 player.peertube().on('autoResolutionChange', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
36 }
37 }
38
39 handleClick (event: any) {
40 // Auto button disabled?
41 if (this.autoResolutionPossible === false && this.id === -1) return
42
43 super.handleClick(event)
44
45 this.callback(this.id, 'video')
46 }
47
48 updateSelection (data: ResolutionUpdateData) {
49 if (this.id === -1) {
50 this.currentResolutionLabel = this.labels[data.id]
51 }
52
53 // Automatic resolution only
54 if (data.auto === true) {
55 this.selected(this.id === -1)
56 return
57 }
58
59 this.selected(this.id === data.id)
60 }
61
62 updateAutoResolution (data: AutoResolutionUpdateData) {
63 // Check if the auto resolution is enabled or not
64 if (data.possible === false) {
65 this.addClass('disabled')
66 } else {
67 this.removeClass('disabled')
68 }
69
70 this.autoResolutionPossible = data.possible
71 }
72
73 getLabel () {
74 if (this.id === -1) {
75 return this.label + ' <small>' + this.currentResolutionLabel + '</small>'
76 }
77
78 return this.label
79 }
80}
81MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
82
83export { ResolutionMenuItem }
diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/videojs-components/settings-menu-button.ts
index a7aefdcc3..14cb8ba43 100644
--- a/client/src/assets/player/settings-menu-button.ts
+++ b/client/src/assets/player/videojs-components/settings-menu-button.ts
@@ -6,8 +6,8 @@
6import * as videojs from 'video.js' 6import * as videojs from 'video.js'
7 7
8import { SettingsMenuItem } from './settings-menu-item' 8import { SettingsMenuItem } from './settings-menu-item'
9import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 9import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
10import { toTitleCase } from './utils' 10import { toTitleCase } from '../utils'
11 11
12const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 12const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
13const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') 13const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu')
diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts
index 698f4627a..f14959f9c 100644
--- a/client/src/assets/player/settings-menu-item.ts
+++ b/client/src/assets/player/videojs-components/settings-menu-item.ts
@@ -5,8 +5,8 @@
5// @ts-ignore 5// @ts-ignore
6import * as videojs from 'video.js' 6import * as videojs from 'video.js'
7 7
8import { toTitleCase } from './utils' 8import { toTitleCase } from '../utils'
9import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 9import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
10 10
11const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') 11const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem')
12const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') 12const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component')
@@ -48,6 +48,19 @@ class SettingsMenuItem extends MenuItem {
48 // Update on rate change 48 // Update on rate change
49 player.on('ratechange', this.submenuClickHandler) 49 player.on('ratechange', this.submenuClickHandler)
50 50
51 if (subMenuName === 'CaptionsButton') {
52 // Hack to regenerate captions on HTTP fallback
53 player.on('captionsChanged', () => {
54 setTimeout(() => {
55 this.settingsSubMenuEl_.innerHTML = ''
56 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
57 this.update()
58 this.bindClickEvents()
59
60 }, 0)
61 })
62 }
63
51 this.reset() 64 this.reset()
52 }, 0) 65 }, 0)
53 }) 66 })
@@ -207,12 +220,14 @@ class SettingsMenuItem extends MenuItem {
207 } 220 }
208 221
209 build () { 222 build () {
210 const saveUpdateLabel = this.subMenu.updateLabel 223 this.subMenu.on('updateLabel', () => {
211 this.subMenu.updateLabel = () => {
212 this.update() 224 this.update()
213 225 })
214 saveUpdateLabel.call(this.subMenu) 226 this.subMenu.on('menuChanged', () => {
215 } 227 this.bindClickEvents()
228 this.setSize()
229 this.update()
230 })
216 231
217 this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) 232 this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_)
218 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) 233 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
@@ -220,7 +235,7 @@ class SettingsMenuItem extends MenuItem {
220 this.update() 235 this.update()
221 236
222 this.createBackButton() 237 this.createBackButton()
223 this.getSize() 238 this.setSize()
224 this.bindClickEvents() 239 this.bindClickEvents()
225 240
226 // prefixed event listeners for CSS TransitionEnd 241 // prefixed event listeners for CSS TransitionEnd
@@ -282,8 +297,9 @@ class SettingsMenuItem extends MenuItem {
282 297
283 // save size of submenus on first init 298 // save size of submenus on first init
284 // if number of submenu items change dynamically more logic will be needed 299 // if number of submenu items change dynamically more logic will be needed
285 getSize () { 300 setSize () {
286 this.dialog.removeClass('vjs-hidden') 301 this.dialog.removeClass('vjs-hidden')
302 videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
287 this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) 303 this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_)
288 this.setMargin() 304 this.setMargin()
289 this.dialog.addClass('vjs-hidden') 305 this.dialog.addClass('vjs-hidden')
diff --git a/client/src/assets/player/theater-button.ts b/client/src/assets/player/videojs-components/theater-button.ts
index 4f8fede3d..1e11a9546 100644
--- a/client/src/assets/player/theater-button.ts
+++ b/client/src/assets/player/videojs-components/theater-button.ts
@@ -2,8 +2,8 @@
2// @ts-ignore 2// @ts-ignore
3import * as videojs from 'video.js' 3import * as videojs from 'video.js'
4 4
5import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 5import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
6import { saveTheaterInStore, getStoredTheater } from './peertube-player-local-storage' 6import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage'
7 7
8const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') 8const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
9class TheaterButton extends Button { 9class TheaterButton extends Button {
diff --git a/client/src/assets/player/peertube-chunk-store.ts b/client/src/assets/player/webtorrent/peertube-chunk-store.ts
index 54cc0ea64..54cc0ea64 100644
--- a/client/src/assets/player/peertube-chunk-store.ts
+++ b/client/src/assets/player/webtorrent/peertube-chunk-store.ts
diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/webtorrent/video-renderer.ts
index a3415937b..a3415937b 100644
--- a/client/src/assets/player/video-renderer.ts
+++ b/client/src/assets/player/webtorrent/video-renderer.ts
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
index 4a280b7ef..c69bf31fa 100644
--- a/client/src/assets/player/peertube-videojs-plugin.ts
+++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
@@ -3,23 +3,18 @@
3import * as videojs from 'video.js' 3import * as videojs from 'video.js'
4 4
5import * as WebTorrent from 'webtorrent' 5import * as WebTorrent from 'webtorrent'
6import { VideoFile } from '../../../../shared/models/videos/video.model' 6import { VideoFile } from '../../../../../shared/models/videos/video.model'
7import { renderVideo } from './video-renderer' 7import { renderVideo } from './video-renderer'
8import './settings-menu-button' 8import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings'
9import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' 9import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
10import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
11import { PeertubeChunkStore } from './peertube-chunk-store' 10import { PeertubeChunkStore } from './peertube-chunk-store'
12import { 11import {
13 getAverageBandwidthInStore, 12 getAverageBandwidthInStore,
14 getStoredLastSubtitle,
15 getStoredMute, 13 getStoredMute,
16 getStoredVolume, 14 getStoredVolume,
17 getStoredWebTorrentEnabled, 15 getStoredWebTorrentEnabled,
18 saveAverageBandwidth, 16 saveAverageBandwidth
19 saveLastSubtitle, 17} from '../peertube-player-local-storage'
20 saveMuteInStore,
21 saveVolumeInStore
22} from './peertube-player-local-storage'
23 18
24const CacheChunkStore = require('cache-chunk-store') 19const CacheChunkStore = require('cache-chunk-store')
25 20
@@ -30,14 +25,13 @@ type PlayOptions = {
30} 25}
31 26
32const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') 27const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
33class PeerTubePlugin extends Plugin { 28class WebTorrentPlugin extends Plugin {
34 private readonly playerElement: HTMLVideoElement 29 private readonly playerElement: HTMLVideoElement
35 30
36 private readonly autoplay: boolean = false 31 private readonly autoplay: boolean = false
37 private readonly startTime: number = 0 32 private readonly startTime: number = 0
38 private readonly savePlayerSrcFunction: Function 33 private readonly savePlayerSrcFunction: Function
39 private readonly videoFiles: VideoFile[] 34 private readonly videoFiles: VideoFile[]
40 private readonly videoViewUrl: string
41 private readonly videoDuration: number 35 private readonly videoDuration: number
42 private readonly CONSTANTS = { 36 private readonly CONSTANTS = {
43 INFO_SCHEDULER: 1000, // Don't change this 37 INFO_SCHEDULER: 1000, // Don't change this
@@ -45,22 +39,12 @@ class PeerTubePlugin extends Plugin {
45 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it 39 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
46 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check 40 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
47 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds 41 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
48 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth 42 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
49 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
50 } 43 }
51 44
52 private readonly webtorrent = new WebTorrent({ 45 private readonly webtorrent = new WebTorrent({
53 tracker: { 46 tracker: {
54 rtcConfig: { 47 rtcConfig: getRtcConfig()
55 iceServers: [
56 {
57 urls: 'stun:stun.stunprotocol.org'
58 },
59 {
60 urls: 'stun:stun.framasoft.org'
61 }
62 ]
63 }
64 }, 48 },
65 dht: false 49 dht: false
66 }) 50 })
@@ -68,46 +52,37 @@ class PeerTubePlugin extends Plugin {
68 private player: any 52 private player: any
69 private currentVideoFile: VideoFile 53 private currentVideoFile: VideoFile
70 private torrent: WebTorrent.Torrent 54 private torrent: WebTorrent.Torrent
71 private videoCaptions: VideoJSCaption[]
72 private defaultSubtitle: string
73 55
74 private renderer: any 56 private renderer: any
75 private fakeRenderer: any 57 private fakeRenderer: any
76 private destroyingFakeRenderer = false 58 private destroyingFakeRenderer = false
77 59
78 private autoResolution = true 60 private autoResolution = true
79 private forbidAutoResolution = false 61 private autoResolutionPossible = true
80 private isAutoResolutionObservation = false 62 private isAutoResolutionObservation = false
81 private playerRefusedP2P = false 63 private playerRefusedP2P = false
82 64
83 private videoViewInterval: any
84 private torrentInfoInterval: any 65 private torrentInfoInterval: any
85 private autoQualityInterval: any 66 private autoQualityInterval: any
86 private userWatchingVideoInterval: any
87 private addTorrentDelay: any 67 private addTorrentDelay: any
88 private qualityObservationTimer: any 68 private qualityObservationTimer: any
89 private runAutoQualitySchedulerTimer: any 69 private runAutoQualitySchedulerTimer: any
90 70
91 private downloadSpeeds: number[] = [] 71 private downloadSpeeds: number[] = []
92 72
93 constructor (player: videojs.Player, options: PeertubePluginOptions) { 73 constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
94 super(player, options) 74 super(player, options)
95 75
96 // Disable auto play on iOS 76 // Disable auto play on iOS
97 this.autoplay = options.autoplay && this.isIOS() === false 77 this.autoplay = options.autoplay && this.isIOS() === false
98 this.playerRefusedP2P = !getStoredWebTorrentEnabled() 78 this.playerRefusedP2P = !getStoredWebTorrentEnabled()
99 79
100 this.startTime = timeToInt(options.startTime)
101 this.videoFiles = options.videoFiles 80 this.videoFiles = options.videoFiles
102 this.videoViewUrl = options.videoViewUrl
103 this.videoDuration = options.videoDuration 81 this.videoDuration = options.videoDuration
104 this.videoCaptions = options.videoCaptions
105 82
106 this.savePlayerSrcFunction = this.player.src 83 this.savePlayerSrcFunction = this.player.src
107 this.playerElement = options.playerElement 84 this.playerElement = options.playerElement
108 85
109 if (this.autoplay === true) this.player.addClass('vjs-has-autoplay')
110
111 this.player.ready(() => { 86 this.player.ready(() => {
112 const playerOptions = this.player.options_ 87 const playerOptions = this.player.options_
113 88
@@ -117,33 +92,10 @@ class PeerTubePlugin extends Plugin {
117 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() 92 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
118 if (muted !== undefined) this.player.muted(muted) 93 if (muted !== undefined) this.player.muted(muted)
119 94
120 this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
121
122 this.player.on('volumechange', () => {
123 saveVolumeInStore(this.player.volume())
124 saveMuteInStore(this.player.muted())
125 })
126
127 this.player.textTracks().on('change', () => {
128 const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
129 return t.kind === 'captions' && t.mode === 'showing'
130 })
131
132 if (!showing) {
133 saveLastSubtitle('off')
134 return
135 }
136
137 saveLastSubtitle(showing.language)
138 })
139
140 this.player.duration(options.videoDuration) 95 this.player.duration(options.videoDuration)
141 96
142 this.initializePlayer() 97 this.initializePlayer()
143 this.runTorrentInfoScheduler() 98 this.runTorrentInfoScheduler()
144 this.runViewAdd()
145
146 if (options.userWatching) this.runUserWatchVideo(options.userWatching)
147 99
148 this.player.one('play', () => { 100 this.player.one('play', () => {
149 // Don't run immediately scheduler, wait some seconds the TCP connections are made 101 // Don't run immediately scheduler, wait some seconds the TCP connections are made
@@ -157,12 +109,9 @@ class PeerTubePlugin extends Plugin {
157 clearTimeout(this.qualityObservationTimer) 109 clearTimeout(this.qualityObservationTimer)
158 clearTimeout(this.runAutoQualitySchedulerTimer) 110 clearTimeout(this.runAutoQualitySchedulerTimer)
159 111
160 clearInterval(this.videoViewInterval)
161 clearInterval(this.torrentInfoInterval) 112 clearInterval(this.torrentInfoInterval)
162 clearInterval(this.autoQualityInterval) 113 clearInterval(this.autoQualityInterval)
163 114
164 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
165
166 // Don't need to destroy renderer, video player will be destroyed 115 // Don't need to destroy renderer, video player will be destroyed
167 this.flushVideoFile(this.currentVideoFile, false) 116 this.flushVideoFile(this.currentVideoFile, false)
168 117
@@ -173,13 +122,6 @@ class PeerTubePlugin extends Plugin {
173 return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 122 return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
174 } 123 }
175 124
176 getCurrentResolutionLabel () {
177 if (!this.currentVideoFile) return ''
178
179 const fps = this.currentVideoFile.fps >= 50 ? this.currentVideoFile.fps : ''
180 return this.currentVideoFile.resolution.label + fps
181 }
182
183 updateVideoFile ( 125 updateVideoFile (
184 videoFile?: VideoFile, 126 videoFile?: VideoFile,
185 options: { 127 options: {
@@ -228,7 +170,8 @@ class PeerTubePlugin extends Plugin {
228 return done() 170 return done()
229 }) 171 })
230 172
231 this.trigger('videoFileUpdate') 173 this.changeQuality()
174 this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
232 } 175 }
233 176
234 updateResolution (resolutionId: number, delay = 0) { 177 updateResolution (resolutionId: number, delay = 0) {
@@ -262,28 +205,17 @@ class PeerTubePlugin extends Plugin {
262 } 205 }
263 } 206 }
264 207
265 isAutoResolutionOn () {
266 return this.autoResolution
267 }
268
269 enableAutoResolution () { 208 enableAutoResolution () {
270 this.autoResolution = true 209 this.autoResolution = true
271 this.trigger('autoResolutionUpdate') 210 this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
272 } 211 }
273 212
274 disableAutoResolution (forbid = false) { 213 disableAutoResolution (forbid = false) {
275 if (forbid === true) this.forbidAutoResolution = true 214 if (forbid === true) this.autoResolutionPossible = false
276 215
277 this.autoResolution = false 216 this.autoResolution = false
278 this.trigger('autoResolutionUpdate') 217 this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible })
279 } 218 this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
280
281 isAutoResolutionForbidden () {
282 return this.forbidAutoResolution === true
283 }
284
285 getCurrentVideoFile () {
286 return this.currentVideoFile
287 } 219 }
288 220
289 getTorrent () { 221 getTorrent () {
@@ -462,13 +394,7 @@ class PeerTubePlugin extends Plugin {
462 } 394 }
463 395
464 private initializePlayer () { 396 private initializePlayer () {
465 if (isMobile()) this.player.addClass('vjs-is-mobile') 397 this.buildQualities()
466
467 this.initSmoothProgressBar()
468
469 this.initCaptions()
470
471 this.alterInactivity()
472 398
473 if (this.autoplay === true) { 399 if (this.autoplay === true) {
474 this.player.posterImage.hide() 400 this.player.posterImage.hide()
@@ -491,7 +417,7 @@ class PeerTubePlugin extends Plugin {
491 417
492 // Not initialized or in HTTP fallback 418 // Not initialized or in HTTP fallback
493 if (this.torrent === undefined || this.torrent === null) return 419 if (this.torrent === undefined || this.torrent === null) return
494 if (this.isAutoResolutionOn() === false) return 420 if (this.autoResolution === false) return
495 if (this.isAutoResolutionObservation === true) return 421 if (this.isAutoResolutionObservation === true) return
496 422
497 const file = this.getAppropriateFile() 423 const file = this.getAppropriateFile()
@@ -531,78 +457,27 @@ class PeerTubePlugin extends Plugin {
531 if (this.torrent === undefined) return 457 if (this.torrent === undefined) return
532 458
533 // Http fallback 459 // Http fallback
534 if (this.torrent === null) return this.trigger('torrentInfo', false) 460 if (this.torrent === null) return this.player.trigger('p2pInfo', false)
535 461
536 // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too 462 // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
537 if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) 463 if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
538 464
539 return this.trigger('torrentInfo', { 465 return this.player.trigger('p2pInfo', {
540 downloadSpeed: this.torrent.downloadSpeed, 466 http: {
541 numPeers: this.torrent.numPeers, 467 downloadSpeed: 0,
542 uploadSpeed: this.torrent.uploadSpeed, 468 uploadSpeed: 0,
543 downloaded: this.torrent.downloaded, 469 downloaded: 0,
544 uploaded: this.torrent.uploaded 470 uploaded: 0
545 }) 471 },
546 }, this.CONSTANTS.INFO_SCHEDULER) 472 p2p: {
547 } 473 downloadSpeed: this.torrent.downloadSpeed,
548 474 numPeers: this.torrent.numPeers,
549 private runViewAdd () { 475 uploadSpeed: this.torrent.uploadSpeed,
550 this.clearVideoViewInterval() 476 downloaded: this.torrent.downloaded,
551 477 uploaded: this.torrent.uploaded
552 // After 30 seconds (or 3/4 of the video), add a view to the video
553 let minSecondsToView = 30
554
555 if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4
556
557 let secondsViewed = 0
558 this.videoViewInterval = setInterval(() => {
559 if (this.player && !this.player.paused()) {
560 secondsViewed += 1
561
562 if (secondsViewed > minSecondsToView) {
563 this.clearVideoViewInterval()
564
565 this.addViewToVideo().catch(err => console.error(err))
566 } 478 }
567 } 479 } as PlayerNetworkInfo)
568 }, 1000) 480 }, this.CONSTANTS.INFO_SCHEDULER)
569 }
570
571 private runUserWatchVideo (options: UserWatching) {
572 let lastCurrentTime = 0
573
574 this.userWatchingVideoInterval = setInterval(() => {
575 const currentTime = Math.floor(this.player.currentTime())
576
577 if (currentTime - lastCurrentTime >= 1) {
578 lastCurrentTime = currentTime
579
580 this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
581 .catch(err => console.error('Cannot notify user is watching.', err))
582 }
583 }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
584 }
585
586 private clearVideoViewInterval () {
587 if (this.videoViewInterval !== undefined) {
588 clearInterval(this.videoViewInterval)
589 this.videoViewInterval = undefined
590 }
591 }
592
593 private addViewToVideo () {
594 if (!this.videoViewUrl) return Promise.resolve(undefined)
595
596 return fetch(this.videoViewUrl, { method: 'POST' })
597 }
598
599 private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
600 const body = new URLSearchParams()
601 body.append('currentTime', currentTime.toString())
602
603 const headers = new Headers({ 'Authorization': authorizationHeader })
604
605 return fetch(url, { method: 'PUT', body, headers })
606 } 481 }
607 482
608 private fallbackToHttp (options: PlayOptions, done?: Function) { 483 private fallbackToHttp (options: PlayOptions, done?: Function) {
@@ -620,6 +495,11 @@ class PeerTubePlugin extends Plugin {
620 this.player.src = this.savePlayerSrcFunction 495 this.player.src = this.savePlayerSrcFunction
621 this.player.src(httpUrl) 496 this.player.src(httpUrl)
622 497
498 this.changeQuality()
499
500 // We changed the source, so reinit captions
501 this.player.trigger('sourcechange')
502
623 return this.tryToPlay(err => { 503 return this.tryToPlay(err => {
624 if (err && done) return done(err) 504 if (err && done) return done(err)
625 505
@@ -646,25 +526,6 @@ class PeerTubePlugin extends Plugin {
646 return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform) 526 return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
647 } 527 }
648 528
649 private alterInactivity () {
650 let saveInactivityTimeout: number
651
652 const disableInactivity = () => {
653 saveInactivityTimeout = this.player.options_.inactivityTimeout
654 this.player.options_.inactivityTimeout = 0
655 }
656 const enableInactivity = () => {
657 this.player.options_.inactivityTimeout = saveInactivityTimeout
658 }
659
660 const settingsDialog = this.player.children_.find((c: any) => c.name_ === 'SettingsDialog')
661
662 this.player.controlBar.on('mouseenter', () => disableInactivity())
663 settingsDialog.on('mouseenter', () => disableInactivity())
664 this.player.controlBar.on('mouseleave', () => enableInactivity())
665 settingsDialog.on('mouseleave', () => enableInactivity())
666 }
667
668 private pickAverageVideoFile () { 529 private pickAverageVideoFile () {
669 if (this.videoFiles.length === 1) return this.videoFiles[0] 530 if (this.videoFiles.length === 1) return this.videoFiles[0]
670 531
@@ -709,41 +570,70 @@ class PeerTubePlugin extends Plugin {
709 } 570 }
710 } 571 }
711 572
712 private initCaptions () { 573 private buildQualities () {
713 for (const caption of this.videoCaptions) { 574 const qualityLevelsPayload = []
714 this.player.addRemoteTextTrack({ 575
715 kind: 'captions', 576 for (const file of this.videoFiles) {
716 label: caption.label, 577 const representation = {
717 language: caption.language, 578 id: file.resolution.id,
718 id: caption.language, 579 label: this.buildQualityLabel(file),
719 src: caption.src, 580 height: file.resolution.id,
720 default: this.defaultSubtitle === caption.language 581 _enabled: true
721 }, false) 582 }
583
584 this.player.qualityLevels().addQualityLevel(representation)
585
586 qualityLevelsPayload.push({
587 id: representation.id,
588 label: representation.label,
589 selected: false
590 })
591 }
592
593 const payload: LoadedQualityData = {
594 qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d),
595 qualityData: {
596 video: qualityLevelsPayload
597 }
722 } 598 }
599 this.player.tech_.trigger('loadedqualitydata', payload)
723 } 600 }
724 601
725 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 602 private buildQualityLabel (file: VideoFile) {
726 private initSmoothProgressBar () { 603 let label = file.resolution.label
727 const SeekBar = videojsUntyped.getComponent('SeekBar') 604
728 SeekBar.prototype.getPercent = function getPercent () { 605 if (file.fps && file.fps >= 50) {
729 // Allows for smooth scrubbing, when player can't keep up. 606 label += file.fps
730 // const time = (this.player_.scrubbing()) ?
731 // this.player_.getCache().currentTime :
732 // this.player_.currentTime()
733 const time = this.player_.currentTime()
734 const percent = time / this.player_.duration()
735 return percent >= 1 ? 1 : percent
736 } 607 }
737 SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { 608
738 let newTime = this.calculateDistance(event) * this.player_.duration() 609 return label
739 if (newTime === this.player_.duration()) { 610 }
740 newTime = newTime - 0.1 611
741 } 612 private qualitySwitchCallback (id: number) {
742 this.player_.currentTime(newTime) 613 if (id === -1) {
743 this.update() 614 if (this.autoResolutionPossible === true) this.enableAutoResolution()
615 return
616 }
617
618 this.disableAutoResolution()
619 this.updateResolution(id)
620 }
621
622 private changeQuality () {
623 const resolutionId = this.currentVideoFile.resolution.id
624 const qualityLevels = this.player.qualityLevels()
625
626 if (resolutionId === -1) {
627 qualityLevels.selectedIndex = -1
628 return
629 }
630
631 for (let i = 0; i < qualityLevels; i++) {
632 const q = this.player.qualityLevels[i]
633 if (q.height === resolutionId) qualityLevels.selectedIndex = i
744 } 634 }
745 } 635 }
746} 636}
747 637
748videojs.registerPlugin('peertube', PeerTubePlugin) 638videojs.registerPlugin('webtorrent', WebTorrentPlugin)
749export { PeerTubePlugin } 639export { WebTorrentPlugin }