diff options
Diffstat (limited to 'client/src/assets/player/peertube-player-manager.ts')
-rw-r--r-- | client/src/assets/player/peertube-player-manager.ts | 388 |
1 files changed, 388 insertions, 0 deletions
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..9155c0698 --- /dev/null +++ b/client/src/assets/player/peertube-player-manager.ts | |||
@@ -0,0 +1,388 @@ | |||
1 | import { VideoFile } from '../../../../shared/models/videos' | ||
2 | // @ts-ignore | ||
3 | import * as videojs from 'video.js' | ||
4 | import 'videojs-hotkeys' | ||
5 | import 'videojs-dock' | ||
6 | import 'videojs-contextmenu-ui' | ||
7 | import 'videojs-contrib-quality-levels' | ||
8 | import './peertube-plugin' | ||
9 | import './videojs-components/peertube-link-button' | ||
10 | import './videojs-components/resolution-menu-button' | ||
11 | import './videojs-components/settings-menu-button' | ||
12 | import './videojs-components/p2p-info-button' | ||
13 | import './videojs-components/peertube-load-progress-bar' | ||
14 | import './videojs-components/theater-button' | ||
15 | import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings' | ||
16 | import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' | ||
17 | import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' | ||
18 | import { Engine } from 'p2p-media-loader-hlsjs' | ||
19 | |||
20 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
21 | videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' | ||
22 | // Change Captions to Subtitles/CC | ||
23 | videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC' | ||
24 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
25 | videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' ' | ||
26 | |||
27 | type PlayerMode = 'webtorrent' | 'p2p-media-loader' | ||
28 | |||
29 | type WebtorrentOptions = { | ||
30 | videoFiles: VideoFile[] | ||
31 | } | ||
32 | |||
33 | type P2PMediaLoaderOptions = { | ||
34 | playlistUrl: string | ||
35 | } | ||
36 | |||
37 | type CommonOptions = { | ||
38 | playerElement: HTMLVideoElement | ||
39 | |||
40 | autoplay: boolean | ||
41 | videoDuration: number | ||
42 | enableHotkeys: boolean | ||
43 | inactivityTimeout: number | ||
44 | poster: string | ||
45 | startTime: number | string | ||
46 | |||
47 | theaterMode: boolean | ||
48 | captions: boolean | ||
49 | peertubeLink: boolean | ||
50 | |||
51 | videoViewUrl: string | ||
52 | embedUrl: string | ||
53 | |||
54 | language?: string | ||
55 | controls?: boolean | ||
56 | muted?: boolean | ||
57 | loop?: boolean | ||
58 | subtitle?: string | ||
59 | |||
60 | videoCaptions: VideoJSCaption[] | ||
61 | |||
62 | userWatching?: UserWatching | ||
63 | |||
64 | serverUrl: string | ||
65 | } | ||
66 | |||
67 | export type PeertubePlayerManagerOptions = { | ||
68 | common: CommonOptions, | ||
69 | webtorrent?: WebtorrentOptions, | ||
70 | p2pMediaLoader?: P2PMediaLoaderOptions | ||
71 | } | ||
72 | |||
73 | export class PeertubePlayerManager { | ||
74 | |||
75 | private static videojsLocaleCache: { [ path: string ]: any } = {} | ||
76 | |||
77 | static getServerTranslations (serverUrl: string, locale: string) { | ||
78 | const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) | ||
79 | // It is the default locale, nothing to translate | ||
80 | if (!path) return Promise.resolve(undefined) | ||
81 | |||
82 | return fetch(path + '/server.json') | ||
83 | .then(res => res.json()) | ||
84 | .catch(err => { | ||
85 | console.error('Cannot get server translations', err) | ||
86 | return undefined | ||
87 | }) | ||
88 | } | ||
89 | |||
90 | static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { | ||
91 | if (mode === 'webtorrent') await import('./webtorrent-plugin') | ||
92 | if (mode === 'p2p-media-loader') await import('./p2p-media-loader-plugin') | ||
93 | |||
94 | const videojsOptions = this.getVideojsOptions(mode, options) | ||
95 | |||
96 | await this.loadLocaleInVideoJS(options.common.serverUrl, options.common.language) | ||
97 | |||
98 | const self = this | ||
99 | return new Promise(res => { | ||
100 | videojs(options.common.playerElement, videojsOptions, function (this: any) { | ||
101 | const player = this | ||
102 | |||
103 | self.addContextMenu(mode, player, options.common.embedUrl) | ||
104 | |||
105 | return res(player) | ||
106 | }) | ||
107 | }) | ||
108 | } | ||
109 | |||
110 | private static loadLocaleInVideoJS (serverUrl: string, locale: string) { | ||
111 | const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) | ||
112 | // It is the default locale, nothing to translate | ||
113 | if (!path) return Promise.resolve(undefined) | ||
114 | |||
115 | let p: Promise<any> | ||
116 | |||
117 | if (PeertubePlayerManager.videojsLocaleCache[path]) { | ||
118 | p = Promise.resolve(PeertubePlayerManager.videojsLocaleCache[path]) | ||
119 | } else { | ||
120 | p = fetch(path + '/player.json') | ||
121 | .then(res => res.json()) | ||
122 | .then(json => { | ||
123 | PeertubePlayerManager.videojsLocaleCache[path] = json | ||
124 | return json | ||
125 | }) | ||
126 | .catch(err => { | ||
127 | console.error('Cannot get player translations', err) | ||
128 | return undefined | ||
129 | }) | ||
130 | } | ||
131 | |||
132 | const completeLocale = getCompleteLocale(locale) | ||
133 | return p.then(json => videojs.addLanguage(getShortLocale(completeLocale), json)) | ||
134 | } | ||
135 | |||
136 | private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions) { | ||
137 | const commonOptions = options.common | ||
138 | const webtorrentOptions = options.webtorrent | ||
139 | const p2pMediaLoaderOptions = options.p2pMediaLoader | ||
140 | |||
141 | const plugins: VideoJSPluginOptions = { | ||
142 | peertube: { | ||
143 | autoplay: commonOptions.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent | ||
144 | videoViewUrl: commonOptions.videoViewUrl, | ||
145 | videoDuration: commonOptions.videoDuration, | ||
146 | startTime: commonOptions.startTime, | ||
147 | userWatching: commonOptions.userWatching, | ||
148 | subtitle: commonOptions.subtitle, | ||
149 | videoCaptions: commonOptions.videoCaptions | ||
150 | } | ||
151 | } | ||
152 | |||
153 | if (p2pMediaLoaderOptions) { | ||
154 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | ||
155 | type: 'application/x-mpegURL', | ||
156 | src: p2pMediaLoaderOptions.playlistUrl | ||
157 | } | ||
158 | |||
159 | const config = { | ||
160 | segments: { | ||
161 | swarmId: 'swarm' // TODO: choose swarm id | ||
162 | } | ||
163 | } | ||
164 | const streamrootHls = { | ||
165 | html5: { | ||
166 | hlsjsConfig: { | ||
167 | liveSyncDurationCount: 7, | ||
168 | loader: new Engine(config).createLoaderClass() | ||
169 | } | ||
170 | } | ||
171 | } | ||
172 | |||
173 | Object.assign(plugins, { p2pMediaLoader, streamrootHls }) | ||
174 | } | ||
175 | |||
176 | if (webtorrentOptions) { | ||
177 | const webtorrent = { | ||
178 | autoplay: commonOptions.autoplay, | ||
179 | videoDuration: commonOptions.videoDuration, | ||
180 | playerElement: commonOptions.playerElement, | ||
181 | videoFiles: webtorrentOptions.videoFiles | ||
182 | } | ||
183 | Object.assign(plugins, { webtorrent }) | ||
184 | } | ||
185 | |||
186 | const videojsOptions = { | ||
187 | // We don't use text track settings for now | ||
188 | textTrackSettings: false, | ||
189 | controls: commonOptions.controls !== undefined ? commonOptions.controls : true, | ||
190 | loop: commonOptions.loop !== undefined ? commonOptions.loop : false, | ||
191 | |||
192 | muted: commonOptions.muted !== undefined | ||
193 | ? commonOptions.muted | ||
194 | : undefined, // Undefined so the player knows it has to check the local storage | ||
195 | |||
196 | poster: commonOptions.poster, | ||
197 | autoplay: false, | ||
198 | inactivityTimeout: commonOptions.inactivityTimeout, | ||
199 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ], | ||
200 | plugins, | ||
201 | controlBar: { | ||
202 | children: this.getControlBarChildren(mode, { | ||
203 | captions: commonOptions.captions, | ||
204 | peertubeLink: commonOptions.peertubeLink, | ||
205 | theaterMode: commonOptions.theaterMode | ||
206 | }) | ||
207 | } | ||
208 | } | ||
209 | |||
210 | if (commonOptions.enableHotkeys === true) { | ||
211 | Object.assign(videojsOptions.plugins, { | ||
212 | hotkeys: { | ||
213 | enableVolumeScroll: false, | ||
214 | enableModifiersForNumbers: false, | ||
215 | |||
216 | fullscreenKey: function (event: KeyboardEvent) { | ||
217 | // fullscreen with the f key or Ctrl+Enter | ||
218 | return event.key === 'f' || (event.ctrlKey && event.key === 'Enter') | ||
219 | }, | ||
220 | |||
221 | seekStep: function (event: KeyboardEvent) { | ||
222 | // mimic VLC seek behavior, and default to 5 (original value is 5). | ||
223 | if (event.ctrlKey && event.altKey) { | ||
224 | return 5 * 60 | ||
225 | } else if (event.ctrlKey) { | ||
226 | return 60 | ||
227 | } else if (event.altKey) { | ||
228 | return 10 | ||
229 | } else { | ||
230 | return 5 | ||
231 | } | ||
232 | }, | ||
233 | |||
234 | customKeys: { | ||
235 | increasePlaybackRateKey: { | ||
236 | key: function (event: KeyboardEvent) { | ||
237 | return event.key === '>' | ||
238 | }, | ||
239 | handler: function (player: videojs.Player) { | ||
240 | player.playbackRate((player.playbackRate() + 0.1).toFixed(2)) | ||
241 | } | ||
242 | }, | ||
243 | decreasePlaybackRateKey: { | ||
244 | key: function (event: KeyboardEvent) { | ||
245 | return event.key === '<' | ||
246 | }, | ||
247 | handler: function (player: videojs.Player) { | ||
248 | player.playbackRate((player.playbackRate() - 0.1).toFixed(2)) | ||
249 | } | ||
250 | }, | ||
251 | frameByFrame: { | ||
252 | key: function (event: KeyboardEvent) { | ||
253 | return event.key === '.' | ||
254 | }, | ||
255 | handler: function (player: videojs.Player) { | ||
256 | player.pause() | ||
257 | // Calculate movement distance (assuming 30 fps) | ||
258 | const dist = 1 / 30 | ||
259 | player.currentTime(player.currentTime() + dist) | ||
260 | } | ||
261 | } | ||
262 | } | ||
263 | } | ||
264 | }) | ||
265 | } | ||
266 | |||
267 | if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { | ||
268 | Object.assign(videojsOptions, { language: commonOptions.language }) | ||
269 | } | ||
270 | |||
271 | return videojsOptions | ||
272 | } | ||
273 | |||
274 | private static getControlBarChildren (mode: PlayerMode, options: { | ||
275 | peertubeLink: boolean | ||
276 | theaterMode: boolean, | ||
277 | captions: boolean | ||
278 | }) { | ||
279 | const settingEntries = [] | ||
280 | const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar' | ||
281 | |||
282 | // Keep an order | ||
283 | settingEntries.push('playbackRateMenuButton') | ||
284 | if (options.captions === true) settingEntries.push('captionsButton') | ||
285 | settingEntries.push('resolutionMenuButton') | ||
286 | |||
287 | const children = { | ||
288 | 'playToggle': {}, | ||
289 | 'currentTimeDisplay': {}, | ||
290 | 'timeDivider': {}, | ||
291 | 'durationDisplay': {}, | ||
292 | 'liveDisplay': {}, | ||
293 | |||
294 | 'flexibleWidthSpacer': {}, | ||
295 | 'progressControl': { | ||
296 | children: { | ||
297 | 'seekBar': { | ||
298 | children: { | ||
299 | [loadProgressBar]: {}, | ||
300 | 'mouseTimeDisplay': {}, | ||
301 | 'playProgressBar': {} | ||
302 | } | ||
303 | } | ||
304 | } | ||
305 | }, | ||
306 | |||
307 | 'p2PInfoButton': {}, | ||
308 | |||
309 | 'muteToggle': {}, | ||
310 | 'volumeControl': {}, | ||
311 | |||
312 | 'settingsButton': { | ||
313 | setup: { | ||
314 | maxHeightOffset: 40 | ||
315 | }, | ||
316 | entries: settingEntries | ||
317 | } | ||
318 | } | ||
319 | |||
320 | if (options.peertubeLink === true) { | ||
321 | Object.assign(children, { | ||
322 | 'peerTubeLinkButton': {} | ||
323 | }) | ||
324 | } | ||
325 | |||
326 | if (options.theaterMode === true) { | ||
327 | Object.assign(children, { | ||
328 | 'theaterButton': {} | ||
329 | }) | ||
330 | } | ||
331 | |||
332 | Object.assign(children, { | ||
333 | 'fullscreenToggle': {} | ||
334 | }) | ||
335 | |||
336 | return children | ||
337 | } | ||
338 | |||
339 | private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) { | ||
340 | const content = [ | ||
341 | { | ||
342 | label: player.localize('Copy the video URL'), | ||
343 | listener: function () { | ||
344 | copyToClipboard(buildVideoLink()) | ||
345 | } | ||
346 | }, | ||
347 | { | ||
348 | label: player.localize('Copy the video URL at the current time'), | ||
349 | listener: function () { | ||
350 | const player = this as videojs.Player | ||
351 | copyToClipboard(buildVideoLink(player.currentTime())) | ||
352 | } | ||
353 | }, | ||
354 | { | ||
355 | label: player.localize('Copy embed code'), | ||
356 | listener: () => { | ||
357 | copyToClipboard(buildVideoEmbed(videoEmbedUrl)) | ||
358 | } | ||
359 | } | ||
360 | ] | ||
361 | |||
362 | if (mode === 'webtorrent') { | ||
363 | content.push({ | ||
364 | label: player.localize('Copy magnet URI'), | ||
365 | listener: function () { | ||
366 | const player = this as videojs.Player | ||
367 | copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri) | ||
368 | } | ||
369 | }) | ||
370 | } | ||
371 | |||
372 | player.contextmenuUI({ content }) | ||
373 | } | ||
374 | |||
375 | private static getLocalePath (serverUrl: string, locale: string) { | ||
376 | const completeLocale = getCompleteLocale(locale) | ||
377 | |||
378 | if (!is18nLocale(completeLocale) || isDefaultLocale(completeLocale)) return undefined | ||
379 | |||
380 | return serverUrl + '/client/locales/' + completeLocale | ||
381 | } | ||
382 | } | ||
383 | |||
384 | // ############################################################################ | ||
385 | |||
386 | export { | ||
387 | videojs | ||
388 | } | ||