diff options
author | Chocobozzz <me@florianbigard.com> | 2023-07-10 16:13:22 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-07-10 16:13:22 +0200 |
commit | 63e2f087c3410206aa294df8099b005e615592e5 (patch) | |
tree | bbc60b9b258f35442e9c6e6104e8df255f3704f1 /client/src | |
parent | 83b42f5a32df30fc8b665d52c7ef707c0c5c8566 (diff) | |
parent | 8953f055c86ca74f145d7ac5ac93bb6104d73af9 (diff) | |
download | PeerTube-63e2f087c3410206aa294df8099b005e615592e5.tar.gz PeerTube-63e2f087c3410206aa294df8099b005e615592e5.tar.zst PeerTube-63e2f087c3410206aa294df8099b005e615592e5.zip |
Merge branch 'feature/remove-webtorrent' into develop
Diffstat (limited to 'client/src')
81 files changed, 2532 insertions, 3066 deletions
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts index ec85db0ff..97d71a510 100644 --- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts +++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts | |||
@@ -152,12 +152,24 @@ export class VideoWatchPlaylistComponent { | |||
152 | this.onPlaylistVideosNearOfBottom(position) | 152 | this.onPlaylistVideosNearOfBottom(position) |
153 | } | 153 | } |
154 | 154 | ||
155 | // --------------------------------------------------------------------------- | ||
156 | |||
155 | hasPreviousVideo () { | 157 | hasPreviousVideo () { |
156 | return !!this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') | 158 | return !!this.getPreviousVideo() |
159 | } | ||
160 | |||
161 | getPreviousVideo () { | ||
162 | return this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') | ||
157 | } | 163 | } |
158 | 164 | ||
165 | // --------------------------------------------------------------------------- | ||
166 | |||
159 | hasNextVideo () { | 167 | hasNextVideo () { |
160 | return !!this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') | 168 | return !!this.getNextVideo() |
169 | } | ||
170 | |||
171 | getNextVideo () { | ||
172 | return this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') | ||
161 | } | 173 | } |
162 | 174 | ||
163 | navigateToPreviousPlaylistVideo () { | 175 | navigateToPreviousPlaylistVideo () { |
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html index 80fd6e40f..294ff4b3a 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.html +++ b/client/src/app/+videos/+video-watch/video-watch.component.html | |||
@@ -8,7 +8,7 @@ | |||
8 | </div> | 8 | </div> |
9 | 9 | ||
10 | <div id="videojs-wrapper"> | 10 | <div id="videojs-wrapper"> |
11 | <img class="placeholder-image" *ngIf="playerPlaceholderImgSrc" [src]="playerPlaceholderImgSrc" alt="Placeholder image" i18n-alt> | 11 | <video #playerElement class="video-js vjs-peertube-skin" playsinline="true"></video> |
12 | </div> | 12 | </div> |
13 | 13 | ||
14 | <my-video-watch-playlist | 14 | <my-video-watch-playlist |
@@ -51,7 +51,7 @@ | |||
51 | </div> | 51 | </div> |
52 | 52 | ||
53 | <my-action-buttons | 53 | <my-action-buttons |
54 | [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions" | 54 | [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions" |
55 | [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" | 55 | [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" |
56 | ></my-action-buttons> | 56 | ></my-action-buttons> |
57 | </div> | 57 | </div> |
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index 54e0649ba..aebec52fb 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
2 | import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' | 2 | import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' |
3 | import { VideoJsPlayer } from 'video.js' | ||
4 | import { PlatformLocation } from '@angular/common' | 3 | import { PlatformLocation } from '@angular/common' |
5 | import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' | 4 | import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' |
6 | import { ActivatedRoute, Router } from '@angular/router' | 5 | import { ActivatedRoute, Router } from '@angular/router' |
@@ -19,13 +18,13 @@ import { | |||
19 | UserService | 18 | UserService |
20 | } from '@app/core' | 19 | } from '@app/core' |
21 | import { HooksService } from '@app/core/plugins/hooks.service' | 20 | import { HooksService } from '@app/core/plugins/hooks.service' |
22 | import { isXPercentInViewport, scrollToTop } from '@app/helpers' | 21 | import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' |
23 | import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' | 22 | import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' |
24 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' | 23 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' |
25 | import { LiveVideoService } from '@app/shared/shared-video-live' | 24 | import { LiveVideoService } from '@app/shared/shared-video-live' |
26 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | 25 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' |
27 | import { logger } from '@root-helpers/logger' | 26 | import { logger } from '@root-helpers/logger' |
28 | import { isP2PEnabled, videoRequiresUserAuth, videoRequiresFileToken } from '@root-helpers/video' | 27 | import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video' |
29 | import { timeToInt } from '@shared/core-utils' | 28 | import { timeToInt } from '@shared/core-utils' |
30 | import { | 29 | import { |
31 | HTMLServerConfig, | 30 | HTMLServerConfig, |
@@ -39,10 +38,10 @@ import { | |||
39 | VideoState | 38 | VideoState |
40 | } from '@shared/models' | 39 | } from '@shared/models' |
41 | import { | 40 | import { |
42 | CustomizationOptions, | 41 | HLSOptions, |
43 | P2PMediaLoaderOptions, | 42 | PeerTubePlayer, |
44 | PeertubePlayerManager, | 43 | PeerTubePlayerContructorOptions, |
45 | PeertubePlayerManagerOptions, | 44 | PeerTubePlayerLoadOptions, |
46 | PlayerMode, | 45 | PlayerMode, |
47 | videojs | 46 | videojs |
48 | } from '../../../assets/player' | 47 | } from '../../../assets/player' |
@@ -50,7 +49,24 @@ import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from | |||
50 | import { environment } from '../../../environments/environment' | 49 | import { environment } from '../../../environments/environment' |
51 | import { VideoWatchPlaylistComponent } from './shared' | 50 | import { VideoWatchPlaylistComponent } from './shared' |
52 | 51 | ||
53 | type URLOptions = CustomizationOptions & { playerMode: PlayerMode } | 52 | type URLOptions = { |
53 | playerMode: PlayerMode | ||
54 | |||
55 | startTime: number | string | ||
56 | stopTime: number | string | ||
57 | |||
58 | controls?: boolean | ||
59 | controlBar?: boolean | ||
60 | |||
61 | muted?: boolean | ||
62 | loop?: boolean | ||
63 | subtitle?: string | ||
64 | resume?: string | ||
65 | |||
66 | peertubeLink: boolean | ||
67 | |||
68 | playbackRate?: number | string | ||
69 | } | ||
54 | 70 | ||
55 | @Component({ | 71 | @Component({ |
56 | selector: 'my-video-watch', | 72 | selector: 'my-video-watch', |
@@ -60,10 +76,9 @@ type URLOptions = CustomizationOptions & { playerMode: PlayerMode } | |||
60 | export class VideoWatchComponent implements OnInit, OnDestroy { | 76 | export class VideoWatchComponent implements OnInit, OnDestroy { |
61 | @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent | 77 | @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent |
62 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent | 78 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent |
79 | @ViewChild('playerElement') playerElement: ElementRef<HTMLVideoElement> | ||
63 | 80 | ||
64 | player: VideoJsPlayer | 81 | peertubePlayer: PeerTubePlayer |
65 | playerElement: HTMLVideoElement | ||
66 | playerPlaceholderImgSrc: string | ||
67 | theaterEnabled = false | 82 | theaterEnabled = false |
68 | 83 | ||
69 | video: VideoDetails = null | 84 | video: VideoDetails = null |
@@ -78,8 +93,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
78 | remoteServerDown = false | 93 | remoteServerDown = false |
79 | noPlaylistVideoFound = false | 94 | noPlaylistVideoFound = false |
80 | 95 | ||
81 | private nextVideoUUID = '' | 96 | private nextRecommendedVideoUUID = '' |
82 | private nextVideoTitle = '' | 97 | private nextRecommendedVideoTitle = '' |
83 | 98 | ||
84 | private videoFileToken: string | 99 | private videoFileToken: string |
85 | 100 | ||
@@ -130,11 +145,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
130 | return this.userService.getAnonymousUser() | 145 | return this.userService.getAnonymousUser() |
131 | } | 146 | } |
132 | 147 | ||
133 | ngOnInit () { | 148 | async ngOnInit () { |
134 | this.serverConfig = this.serverService.getHTMLConfig() | 149 | this.serverConfig = this.serverService.getHTMLConfig() |
135 | 150 | ||
136 | PeertubePlayerManager.initState() | ||
137 | |||
138 | this.loadRouteParams() | 151 | this.loadRouteParams() |
139 | this.loadRouteQuery() | 152 | this.loadRouteQuery() |
140 | 153 | ||
@@ -143,10 +156,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
143 | this.hooks.runAction('action:video-watch.init', 'video-watch') | 156 | this.hooks.runAction('action:video-watch.init', 'video-watch') |
144 | 157 | ||
145 | setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI | 158 | setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI |
159 | |||
160 | const constructorOptions = await this.hooks.wrapFun( | ||
161 | this.buildPeerTubePlayerConstructorOptions.bind(this), | ||
162 | { urlOptions: this.getUrlOptions() }, | ||
163 | 'video-watch', | ||
164 | 'filter:internal.video-watch.player.build-options.params', | ||
165 | 'filter:internal.video-watch.player.build-options.result' | ||
166 | ) | ||
167 | |||
168 | this.peertubePlayer = new PeerTubePlayer(constructorOptions) | ||
146 | } | 169 | } |
147 | 170 | ||
148 | ngOnDestroy () { | 171 | ngOnDestroy () { |
149 | this.flushPlayer() | 172 | if (this.peertubePlayer) this.peertubePlayer.destroy() |
150 | 173 | ||
151 | // Unsubscribe subscriptions | 174 | // Unsubscribe subscriptions |
152 | if (this.paramsSub) this.paramsSub.unsubscribe() | 175 | if (this.paramsSub) this.paramsSub.unsubscribe() |
@@ -171,14 +194,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
171 | 194 | ||
172 | // The recommended videos's first element should be the next video | 195 | // The recommended videos's first element should be the next video |
173 | const video = videos[0] | 196 | const video = videos[0] |
174 | this.nextVideoUUID = video.uuid | 197 | this.nextRecommendedVideoUUID = video.uuid |
175 | this.nextVideoTitle = video.name | 198 | this.nextRecommendedVideoTitle = video.name |
176 | } | 199 | } |
177 | 200 | ||
178 | handleTimestampClicked (timestamp: number) { | 201 | handleTimestampClicked (timestamp: number) { |
179 | if (!this.player || this.video.isLive) return | 202 | if (!this.peertubePlayer || this.video.isLive) return |
180 | 203 | ||
181 | this.player.currentTime(timestamp) | 204 | this.peertubePlayer.getPlayer().currentTime(timestamp) |
182 | scrollToTop() | 205 | scrollToTop() |
183 | } | 206 | } |
184 | 207 | ||
@@ -243,7 +266,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
243 | this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition) | 266 | this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition) |
244 | 267 | ||
245 | const start = queryParams['start'] | 268 | const start = queryParams['start'] |
246 | if (this.player && start) this.player.currentTime(parseInt(start, 10)) | 269 | if (this.peertubePlayer && start) this.peertubePlayer.getPlayer().currentTime(parseInt(start, 10)) |
247 | }) | 270 | }) |
248 | } | 271 | } |
249 | 272 | ||
@@ -256,8 +279,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
256 | 279 | ||
257 | if (this.isSameElement(this.video, videoId)) return | 280 | if (this.isSameElement(this.video, videoId)) return |
258 | 281 | ||
259 | if (this.player) this.player.pause() | ||
260 | |||
261 | this.video = undefined | 282 | this.video = undefined |
262 | 283 | ||
263 | const videoObs = this.hooks.wrapObsFun( | 284 | const videoObs = this.hooks.wrapObsFun( |
@@ -291,23 +312,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
291 | this.userService.getAnonymousOrLoggedUser() | 312 | this.userService.getAnonymousOrLoggedUser() |
292 | ]).subscribe({ | 313 | ]).subscribe({ |
293 | next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { | 314 | next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { |
294 | const queryParams = this.route.snapshot.queryParams | ||
295 | |||
296 | const urlOptions = { | ||
297 | resume: queryParams.resume, | ||
298 | |||
299 | startTime: queryParams.start, | ||
300 | stopTime: queryParams.stop, | ||
301 | |||
302 | muted: queryParams.muted, | ||
303 | loop: queryParams.loop, | ||
304 | subtitle: queryParams.subtitle, | ||
305 | |||
306 | playerMode: queryParams.mode, | ||
307 | playbackRate: queryParams.playbackRate, | ||
308 | peertubeLink: false | ||
309 | } | ||
310 | |||
311 | this.onVideoFetched({ | 315 | this.onVideoFetched({ |
312 | video, | 316 | video, |
313 | live, | 317 | live, |
@@ -316,7 +320,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
316 | videoFileToken, | 320 | videoFileToken, |
317 | videoPassword, | 321 | videoPassword, |
318 | loggedInOrAnonymousUser, | 322 | loggedInOrAnonymousUser, |
319 | urlOptions, | ||
320 | forceAutoplay | 323 | forceAutoplay |
321 | }).catch(err => { | 324 | }).catch(err => { |
322 | this.handleGlobalError(err) | 325 | this.handleGlobalError(err) |
@@ -386,14 +389,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
386 | const errorMessage: string = typeof err === 'string' ? err : err.message | 389 | const errorMessage: string = typeof err === 'string' ? err : err.message |
387 | if (!errorMessage) return | 390 | if (!errorMessage) return |
388 | 391 | ||
389 | // Display a message in the video player instead of a notification | ||
390 | if (errorMessage.includes('from xs param')) { | ||
391 | this.flushPlayer() | ||
392 | this.remoteServerDown = true | ||
393 | |||
394 | return | ||
395 | } | ||
396 | |||
397 | this.notifier.error(errorMessage) | 392 | this.notifier.error(errorMessage) |
398 | } | 393 | } |
399 | 394 | ||
@@ -422,7 +417,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
422 | videoFileToken: string | 417 | videoFileToken: string |
423 | videoPassword: string | 418 | videoPassword: string |
424 | 419 | ||
425 | urlOptions: URLOptions | ||
426 | loggedInOrAnonymousUser: User | 420 | loggedInOrAnonymousUser: User |
427 | forceAutoplay: boolean | 421 | forceAutoplay: boolean |
428 | }) { | 422 | }) { |
@@ -431,7 +425,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
431 | live, | 425 | live, |
432 | videoCaptions, | 426 | videoCaptions, |
433 | storyboards, | 427 | storyboards, |
434 | urlOptions, | ||
435 | videoFileToken, | 428 | videoFileToken, |
436 | videoPassword, | 429 | videoPassword, |
437 | loggedInOrAnonymousUser, | 430 | loggedInOrAnonymousUser, |
@@ -448,7 +441,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
448 | this.storyboards = storyboards | 441 | this.storyboards = storyboards |
449 | 442 | ||
450 | // Re init attributes | 443 | // Re init attributes |
451 | this.playerPlaceholderImgSrc = undefined | ||
452 | this.remoteServerDown = false | 444 | this.remoteServerDown = false |
453 | this.currentTime = undefined | 445 | this.currentTime = undefined |
454 | 446 | ||
@@ -462,7 +454,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
462 | 454 | ||
463 | this.buildHotkeysHelp(video) | 455 | this.buildHotkeysHelp(video) |
464 | 456 | ||
465 | this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) | 457 | this.loadPlayer({ loggedInOrAnonymousUser, forceAutoplay }) |
466 | .catch(err => logger.error('Cannot build the player', err)) | 458 | .catch(err => logger.error('Cannot build the player', err)) |
467 | 459 | ||
468 | this.setOpenGraphTags() | 460 | this.setOpenGraphTags() |
@@ -475,28 +467,19 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
475 | this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions) | 467 | this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions) |
476 | } | 468 | } |
477 | 469 | ||
478 | private async buildPlayer (options: { | 470 | private async loadPlayer (options: { |
479 | urlOptions: URLOptions | ||
480 | loggedInOrAnonymousUser: User | 471 | loggedInOrAnonymousUser: User |
481 | forceAutoplay: boolean | 472 | forceAutoplay: boolean |
482 | }) { | 473 | }) { |
483 | const { urlOptions, loggedInOrAnonymousUser, forceAutoplay } = options | 474 | const { loggedInOrAnonymousUser, forceAutoplay } = options |
484 | |||
485 | // Flush old player if needed | ||
486 | this.flushPlayer() | ||
487 | 475 | ||
488 | const videoState = this.video.state.id | 476 | const videoState = this.video.state.id |
489 | if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { | 477 | if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { |
490 | this.playerPlaceholderImgSrc = this.video.previewPath | 478 | this.updatePlayerOnNoLive() |
491 | return | 479 | return |
492 | } | 480 | } |
493 | 481 | ||
494 | // Build video element, because videojs removes it on dispose | 482 | this.peertubePlayer?.enable() |
495 | const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper') | ||
496 | this.playerElement = document.createElement('video') | ||
497 | this.playerElement.className = 'video-js vjs-peertube-skin' | ||
498 | this.playerElement.setAttribute('playsinline', 'true') | ||
499 | playerElementWrapper.appendChild(this.playerElement) | ||
500 | 483 | ||
501 | const params = { | 484 | const params = { |
502 | video: this.video, | 485 | video: this.video, |
@@ -505,86 +488,49 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
505 | liveVideo: this.liveVideo, | 488 | liveVideo: this.liveVideo, |
506 | videoFileToken: this.videoFileToken, | 489 | videoFileToken: this.videoFileToken, |
507 | videoPassword: this.videoPassword, | 490 | videoPassword: this.videoPassword, |
508 | urlOptions, | 491 | urlOptions: this.getUrlOptions(), |
509 | loggedInOrAnonymousUser, | 492 | loggedInOrAnonymousUser, |
510 | forceAutoplay, | 493 | forceAutoplay, |
511 | user: this.user | 494 | user: this.user |
512 | } | 495 | } |
513 | const { playerMode, playerOptions } = await this.hooks.wrapFun( | 496 | |
514 | this.buildPlayerManagerOptions.bind(this), | 497 | const loadOptions = await this.hooks.wrapFun( |
498 | this.buildPeerTubePlayerLoadOptions.bind(this), | ||
515 | params, | 499 | params, |
516 | 'video-watch', | 500 | 'video-watch', |
517 | 'filter:internal.video-watch.player.build-options.params', | 501 | 'filter:internal.video-watch.player.load-options.params', |
518 | 'filter:internal.video-watch.player.build-options.result' | 502 | 'filter:internal.video-watch.player.load-options.result' |
519 | ) | 503 | ) |
520 | 504 | ||
521 | this.zone.runOutsideAngular(async () => { | 505 | this.zone.runOutsideAngular(async () => { |
522 | this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) | 506 | await this.peertubePlayer.load(loadOptions) |
523 | 507 | ||
524 | this.player.on('customError', (_e, data: any) => { | 508 | const player = this.peertubePlayer.getPlayer() |
525 | this.zone.run(() => this.handleGlobalError(data.err)) | ||
526 | }) | ||
527 | 509 | ||
528 | this.player.on('timeupdate', () => { | 510 | player.on('timeupdate', () => { |
529 | // Don't need to trigger angular change for this variable, that is sent to children components on click | 511 | // Don't need to trigger angular change for this variable, that is sent to children components on click |
530 | this.currentTime = Math.floor(this.player.currentTime()) | 512 | this.currentTime = Math.floor(player.currentTime()) |
531 | }) | 513 | }) |
532 | 514 | ||
533 | /** | 515 | if (this.video.isLive) { |
534 | * condition: true to make the upnext functionality trigger, false to disable the upnext functionality | 516 | player.one('ended', () => { |
535 | * go to the next video in 'condition()' if you don't want of the timer. | 517 | this.zone.run(() => { |
536 | * next: function triggered at the end of the timer. | 518 | // We changed the video, it's not a live anymore |
537 | * suspended: function used at each click of the timer checking if we need to reset progress | 519 | if (!this.video.isLive) return |
538 | * and wait until suspended becomes truthy again. | ||
539 | */ | ||
540 | this.player.upnext({ | ||
541 | timeout: 5000, // 5s | ||
542 | |||
543 | headText: $localize`Up Next`, | ||
544 | cancelText: $localize`Cancel`, | ||
545 | suspendedText: $localize`Autoplay is suspended`, | ||
546 | |||
547 | getTitle: () => this.nextVideoTitle, | ||
548 | 520 | ||
549 | next: () => this.zone.run(() => this.playNextVideoInAngularZone()), | 521 | this.video.state.id = VideoState.LIVE_ENDED |
550 | condition: () => { | ||
551 | if (!this.playlist) return this.isAutoPlayNext() | ||
552 | 522 | ||
553 | // Don't wait timeout to play the next playlist video | 523 | this.updatePlayerOnNoLive() |
554 | if (this.isPlaylistAutoPlayNext()) { | 524 | }) |
555 | this.playNextVideoInAngularZone() | 525 | }) |
556 | return undefined | 526 | } |
557 | } | ||
558 | |||
559 | return false | ||
560 | }, | ||
561 | |||
562 | suspended: () => { | ||
563 | return ( | ||
564 | !isXPercentInViewport(this.player.el() as HTMLElement, 80) || | ||
565 | !document.getElementById('content').contains(document.activeElement) | ||
566 | ) | ||
567 | } | ||
568 | }) | ||
569 | |||
570 | this.player.one('stopped', () => { | ||
571 | if (this.playlist && this.isPlaylistAutoPlayNext()) { | ||
572 | this.playNextVideoInAngularZone() | ||
573 | } | ||
574 | }) | ||
575 | |||
576 | this.player.one('ended', () => { | ||
577 | if (this.video.isLive) { | ||
578 | this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED) | ||
579 | } | ||
580 | }) | ||
581 | 527 | ||
582 | this.player.on('theaterChange', (_: any, enabled: boolean) => { | 528 | player.on('theater-change', (_: any, enabled: boolean) => { |
583 | this.zone.run(() => this.theaterEnabled = enabled) | 529 | this.zone.run(() => this.theaterEnabled = enabled) |
584 | }) | 530 | }) |
585 | 531 | ||
586 | this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { | 532 | this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { |
587 | player: this.player, | 533 | player, |
588 | playlist: this.playlist, | 534 | playlist: this.playlist, |
589 | playlistPosition: this.playlistPosition, | 535 | playlistPosition: this.playlistPosition, |
590 | videojs, | 536 | videojs, |
@@ -601,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
601 | return true | 547 | return true |
602 | } | 548 | } |
603 | 549 | ||
604 | private playNextVideoInAngularZone () { | 550 | private getNextVideoTitle () { |
605 | if (this.playlist) { | 551 | if (this.playlist) { |
606 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | 552 | return this.videoWatchPlaylist.getNextVideo()?.video?.name || '' |
607 | return | ||
608 | } | 553 | } |
609 | 554 | ||
610 | if (this.nextVideoUUID) { | 555 | return this.nextRecommendedVideoTitle |
611 | this.router.navigate([ '/w', this.nextVideoUUID ]) | 556 | } |
612 | } | 557 | |
558 | private playNextVideoInAngularZone () { | ||
559 | this.zone.run(() => { | ||
560 | if (this.playlist) { | ||
561 | this.videoWatchPlaylist.navigateToNextPlaylistVideo() | ||
562 | return | ||
563 | } | ||
564 | |||
565 | if (this.nextRecommendedVideoUUID) { | ||
566 | this.router.navigate([ '/w', this.nextRecommendedVideoUUID ]) | ||
567 | } | ||
568 | }) | ||
613 | } | 569 | } |
614 | 570 | ||
615 | private isAutoplay () { | 571 | private isAutoplay () { |
@@ -637,19 +593,45 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
637 | ) | 593 | ) |
638 | } | 594 | } |
639 | 595 | ||
640 | private flushPlayer () { | 596 | private buildPeerTubePlayerConstructorOptions (options: { |
641 | // Remove player if it exists | 597 | urlOptions: URLOptions |
642 | if (!this.player) return | 598 | }): PeerTubePlayerContructorOptions { |
599 | const { urlOptions } = options | ||
600 | |||
601 | return { | ||
602 | playerElement: () => this.playerElement.nativeElement, | ||
603 | |||
604 | enableHotkeys: true, | ||
605 | inactivityTimeout: 2500, | ||
606 | |||
607 | theaterButton: true, | ||
608 | |||
609 | controls: urlOptions.controls, | ||
610 | controlBar: urlOptions.controlBar, | ||
611 | |||
612 | muted: urlOptions.muted, | ||
613 | loop: urlOptions.loop, | ||
614 | |||
615 | playbackRate: urlOptions.playbackRate, | ||
616 | |||
617 | instanceName: this.serverConfig.instance.name, | ||
618 | language: this.localeId, | ||
619 | metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', | ||
620 | |||
621 | videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS, | ||
622 | authorizationHeader: () => this.authService.getRequestHeaderValue(), | ||
623 | |||
624 | serverUrl: environment.originServerUrl || window.location.origin, | ||
643 | 625 | ||
644 | try { | 626 | errorNotifier: (message: string) => this.notifier.error(message), |
645 | this.player.dispose() | 627 | |
646 | this.player = undefined | 628 | peertubeLink: () => false, |
647 | } catch (err) { | 629 | |
648 | logger.error('Cannot dispose player.', err) | 630 | pluginsManager: this.pluginService.getPluginsManager() |
649 | } | 631 | } |
650 | } | 632 | } |
651 | 633 | ||
652 | private buildPlayerManagerOptions (params: { | 634 | private buildPeerTubePlayerLoadOptions (options: { |
653 | video: VideoDetails | 635 | video: VideoDetails |
654 | liveVideo: LiveVideo | 636 | liveVideo: LiveVideo |
655 | videoCaptions: VideoCaption[] | 637 | videoCaptions: VideoCaption[] |
@@ -658,12 +640,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
658 | videoFileToken: string | 640 | videoFileToken: string |
659 | videoPassword: string | 641 | videoPassword: string |
660 | 642 | ||
661 | urlOptions: CustomizationOptions & { playerMode: PlayerMode } | 643 | urlOptions: URLOptions |
662 | 644 | ||
663 | loggedInOrAnonymousUser: User | 645 | loggedInOrAnonymousUser: User |
664 | forceAutoplay: boolean | 646 | forceAutoplay: boolean |
665 | user?: AuthUser // Keep for plugins | 647 | user?: AuthUser // Keep for plugins |
666 | }) { | 648 | }): PeerTubePlayerLoadOptions { |
667 | const { | 649 | const { |
668 | video, | 650 | video, |
669 | liveVideo, | 651 | liveVideo, |
@@ -674,7 +656,30 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
674 | urlOptions, | 656 | urlOptions, |
675 | loggedInOrAnonymousUser, | 657 | loggedInOrAnonymousUser, |
676 | forceAutoplay | 658 | forceAutoplay |
677 | } = params | 659 | } = options |
660 | |||
661 | let mode: PlayerMode | ||
662 | |||
663 | if (urlOptions.playerMode) { | ||
664 | if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' | ||
665 | else mode = 'web-video' | ||
666 | } else { | ||
667 | if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' | ||
668 | else mode = 'web-video' | ||
669 | } | ||
670 | |||
671 | let hlsOptions: HLSOptions | ||
672 | if (video.hasHlsPlaylist()) { | ||
673 | const hlsPlaylist = video.getHlsPlaylist() | ||
674 | |||
675 | hlsOptions = { | ||
676 | playlistUrl: hlsPlaylist.playlistUrl, | ||
677 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, | ||
678 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), | ||
679 | trackerAnnounce: video.trackerUrls, | ||
680 | videoFiles: hlsPlaylist.files | ||
681 | } | ||
682 | } | ||
678 | 683 | ||
679 | const getStartTime = () => { | 684 | const getStartTime = () => { |
680 | const byUrl = urlOptions.startTime !== undefined | 685 | const byUrl = urlOptions.startTime !== undefined |
@@ -714,118 +719,80 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
714 | ? { latencyMode: liveVideo.latencyMode } | 719 | ? { latencyMode: liveVideo.latencyMode } |
715 | : undefined | 720 | : undefined |
716 | 721 | ||
717 | const options: PeertubePlayerManagerOptions = { | 722 | return { |
718 | common: { | 723 | mode, |
719 | autoplay: this.isAutoplay(), | ||
720 | forceAutoplay, | ||
721 | p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled), | ||
722 | |||
723 | hasNextVideo: () => this.hasNextVideo(), | ||
724 | nextVideo: () => this.playNextVideoInAngularZone(), | ||
725 | |||
726 | playerElement: this.playerElement, | ||
727 | onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, | ||
728 | 724 | ||
729 | videoDuration: video.duration, | 725 | autoplay: this.isAutoplay(), |
730 | enableHotkeys: true, | 726 | forceAutoplay, |
731 | inactivityTimeout: 2500, | ||
732 | poster: video.previewUrl, | ||
733 | |||
734 | startTime, | ||
735 | stopTime: urlOptions.stopTime, | ||
736 | controlBar: urlOptions.controlBar, | ||
737 | controls: urlOptions.controls, | ||
738 | muted: urlOptions.muted, | ||
739 | loop: urlOptions.loop, | ||
740 | subtitle: urlOptions.subtitle, | ||
741 | playbackRate: urlOptions.playbackRate, | ||
742 | |||
743 | peertubeLink: urlOptions.peertubeLink, | ||
744 | 727 | ||
745 | theaterButton: true, | 728 | duration: this.video.duration, |
746 | captions: videoCaptions.length !== 0, | 729 | poster: video.previewUrl, |
730 | p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled), | ||
747 | 731 | ||
748 | embedUrl: video.embedUrl, | 732 | startTime, |
749 | embedTitle: video.name, | 733 | stopTime: urlOptions.stopTime, |
750 | instanceName: this.serverConfig.instance.name, | ||
751 | 734 | ||
752 | isLive: video.isLive, | 735 | embedUrl: video.embedUrl, |
753 | liveOptions, | 736 | embedTitle: video.name, |
754 | 737 | ||
755 | language: this.localeId, | 738 | isLive: video.isLive, |
739 | liveOptions, | ||
756 | 740 | ||
757 | metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', | 741 | videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE |
742 | ? this.videoService.getVideoViewUrl(video.uuid) | ||
743 | : null, | ||
758 | 744 | ||
759 | videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE | 745 | videoFileToken: () => videoFileToken, |
760 | ? this.videoService.getVideoViewUrl(video.uuid) | 746 | requiresUserAuth: videoRequiresUserAuth(video, videoPassword), |
761 | : null, | 747 | requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && |
762 | videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS, | 748 | !video.canAccessPasswordProtectedVideoWithoutPassword(this.user), |
763 | authorizationHeader: () => this.authService.getRequestHeaderValue(), | 749 | videoPassword: () => videoPassword, |
764 | 750 | ||
765 | serverUrl: environment.originServerUrl || window.location.origin, | 751 | videoCaptions: playerCaptions, |
752 | storyboard, | ||
766 | 753 | ||
767 | videoFileToken: () => videoFileToken, | 754 | videoShortUUID: video.shortUUID, |
768 | requiresUserAuth: videoRequiresUserAuth(video, videoPassword), | 755 | videoUUID: video.uuid, |
769 | requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && | ||
770 | !video.canAccessPasswordProtectedVideoWithoutPassword(this.user), | ||
771 | videoPassword: () => videoPassword, | ||
772 | 756 | ||
773 | videoCaptions: playerCaptions, | 757 | previousVideo: { |
774 | storyboard, | 758 | enabled: this.playlist && this.videoWatchPlaylist.hasPreviousVideo(), |
775 | 759 | ||
776 | videoShortUUID: video.shortUUID, | 760 | handler: this.playlist |
777 | videoUUID: video.uuid, | 761 | ? () => this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo()) |
762 | : undefined, | ||
778 | 763 | ||
779 | errorNotifier: (message: string) => this.notifier.error(message) | 764 | displayControlBarButton: !!this.playlist |
780 | }, | 765 | }, |
781 | 766 | ||
782 | webtorrent: { | 767 | nextVideo: { |
783 | videoFiles: video.files | 768 | enabled: this.hasNextVideo(), |
769 | handler: () => this.playNextVideoInAngularZone(), | ||
770 | getVideoTitle: () => this.getNextVideoTitle(), | ||
771 | displayControlBarButton: this.hasNextVideo() | ||
784 | }, | 772 | }, |
785 | 773 | ||
786 | pluginsManager: this.pluginService.getPluginsManager() | 774 | upnext: { |
787 | } | 775 | isEnabled: () => { |
788 | 776 | if (this.playlist) return this.isPlaylistAutoPlayNext() | |
789 | // Only set this if we're in a playlist | ||
790 | if (this.playlist) { | ||
791 | options.common.hasPreviousVideo = () => this.videoWatchPlaylist.hasPreviousVideo() | ||
792 | |||
793 | options.common.previousVideo = () => { | ||
794 | this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo()) | ||
795 | } | ||
796 | } | ||
797 | |||
798 | let mode: PlayerMode | ||
799 | 777 | ||
800 | if (urlOptions.playerMode) { | 778 | return this.isAutoPlayNext() |
801 | if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' | 779 | }, |
802 | else mode = 'webtorrent' | ||
803 | } else { | ||
804 | if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' | ||
805 | else mode = 'webtorrent' | ||
806 | } | ||
807 | 780 | ||
808 | // FIXME: remove, we don't support these old web browsers anymore | 781 | isSuspended: (player: videojs.Player) => { |
809 | // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available | 782 | return !isXPercentInViewport(player.el() as HTMLElement, 80) |
810 | if (typeof TextEncoder === 'undefined') { | 783 | }, |
811 | mode = 'webtorrent' | ||
812 | } | ||
813 | 784 | ||
814 | if (mode === 'p2p-media-loader') { | 785 | timeout: this.playlist |
815 | const hlsPlaylist = video.getHlsPlaylist() | 786 | ? 0 // Don't wait to play next video in playlist |
787 | : 5000 // 5 seconds for a recommended video | ||
788 | }, | ||
816 | 789 | ||
817 | const p2pMediaLoader = { | 790 | hls: hlsOptions, |
818 | playlistUrl: hlsPlaylist.playlistUrl, | ||
819 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, | ||
820 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), | ||
821 | trackerAnnounce: video.trackerUrls, | ||
822 | videoFiles: hlsPlaylist.files | ||
823 | } as P2PMediaLoaderOptions | ||
824 | 791 | ||
825 | Object.assign(options, { p2pMediaLoader }) | 792 | webVideo: { |
793 | videoFiles: video.files | ||
794 | } | ||
826 | } | 795 | } |
827 | |||
828 | return { playerMode: mode, playerOptions: options } | ||
829 | } | 796 | } |
830 | 797 | ||
831 | private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) { | 798 | private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) { |
@@ -873,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
873 | this.video.viewers = newViewers | 840 | this.video.viewers = newViewers |
874 | } | 841 | } |
875 | 842 | ||
843 | private updatePlayerOnNoLive () { | ||
844 | this.peertubePlayer.unload() | ||
845 | this.peertubePlayer.disable() | ||
846 | this.peertubePlayer.setPoster(this.video.previewPath) | ||
847 | } | ||
848 | |||
876 | private buildHotkeysHelp (video: Video) { | 849 | private buildHotkeysHelp (video: Video) { |
877 | if (this.hotkeys.length !== 0) { | 850 | if (this.hotkeys.length !== 0) { |
878 | this.hotkeysService.remove(this.hotkeys) | 851 | this.hotkeysService.remove(this.hotkeys) |
@@ -944,4 +917,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
944 | this.metaService.setTag('og:url', window.location.href) | 917 | this.metaService.setTag('og:url', window.location.href) |
945 | this.metaService.setTag('url', window.location.href) | 918 | this.metaService.setTag('url', window.location.href) |
946 | } | 919 | } |
920 | |||
921 | private getUrlOptions (): URLOptions { | ||
922 | const queryParams = this.route.snapshot.queryParams | ||
923 | |||
924 | return { | ||
925 | resume: queryParams.resume, | ||
926 | |||
927 | startTime: queryParams.start, | ||
928 | stopTime: queryParams.stop, | ||
929 | |||
930 | muted: toBoolean(queryParams.muted), | ||
931 | loop: toBoolean(queryParams.loop), | ||
932 | subtitle: queryParams.subtitle, | ||
933 | |||
934 | playerMode: queryParams.mode, | ||
935 | playbackRate: queryParams.playbackRate, | ||
936 | |||
937 | controlBar: toBoolean(queryParams.controlBar), | ||
938 | |||
939 | peertubeLink: false | ||
940 | } | ||
941 | } | ||
947 | } | 942 | } |
diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts index 69b2b18c0..b69e31edf 100644 --- a/client/src/app/helpers/utils/object.ts +++ b/client/src/app/helpers/utils/object.ts | |||
@@ -34,6 +34,8 @@ function toBoolean (value: any) { | |||
34 | 34 | ||
35 | if (value === 'true') return true | 35 | if (value === 'true') return true |
36 | if (value === 'false') return false | 36 | if (value === 'false') return false |
37 | if (value === '1') return true | ||
38 | if (value === '0') return false | ||
37 | 39 | ||
38 | return undefined | 40 | return undefined |
39 | } | 41 | } |
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts index 45df0be38..14a5abd7a 100644 --- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts | |||
@@ -241,7 +241,6 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | |||
241 | } | 241 | } |
242 | 242 | ||
243 | reloadVideos () { | 243 | reloadVideos () { |
244 | console.log('reload') | ||
245 | this.pagination.currentPage = 1 | 244 | this.pagination.currentPage = 1 |
246 | this.loadMoreVideos(true) | 245 | this.loadMoreVideos(true) |
247 | } | 246 | } |
@@ -420,8 +419,9 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | |||
420 | this.lastQueryLength = data.length | 419 | this.lastQueryLength = data.length |
421 | 420 | ||
422 | if (reset) this.videos = [] | 421 | if (reset) this.videos = [] |
422 | |||
423 | this.videos = this.videos.concat(data) | 423 | this.videos = this.videos.concat(data) |
424 | console.log('subscribe') | 424 | |
425 | if (this.groupByDate) this.buildGroupedDateLabels() | 425 | if (this.groupByDate) this.buildGroupedDateLabels() |
426 | 426 | ||
427 | this.onDataSubject.next(data) | 427 | this.onDataSubject.next(data) |
diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts index 9b87afc4a..d34188ea7 100644 --- a/client/src/assets/player/index.ts +++ b/client/src/assets/player/index.ts | |||
@@ -1,2 +1,2 @@ | |||
1 | export * from './peertube-player-manager' | 1 | export * from './peertube-player' |
2 | export * from './types' | 2 | export * from './types' |
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts deleted file mode 100644 index 66d9c7298..000000000 --- a/client/src/assets/player/peertube-player-manager.ts +++ /dev/null | |||
@@ -1,277 +0,0 @@ | |||
1 | import '@peertube/videojs-contextmenu' | ||
2 | import './shared/upnext/end-card' | ||
3 | import './shared/upnext/upnext-plugin' | ||
4 | import './shared/stats/stats-card' | ||
5 | import './shared/stats/stats-plugin' | ||
6 | import './shared/bezels/bezels-plugin' | ||
7 | import './shared/peertube/peertube-plugin' | ||
8 | import './shared/resolutions/peertube-resolutions-plugin' | ||
9 | import './shared/control-bar/storyboard-plugin' | ||
10 | import './shared/control-bar/next-previous-video-button' | ||
11 | import './shared/control-bar/p2p-info-button' | ||
12 | import './shared/control-bar/peertube-link-button' | ||
13 | import './shared/control-bar/peertube-load-progress-bar' | ||
14 | import './shared/control-bar/theater-button' | ||
15 | import './shared/control-bar/peertube-live-display' | ||
16 | import './shared/settings/resolution-menu-button' | ||
17 | import './shared/settings/resolution-menu-item' | ||
18 | import './shared/settings/settings-dialog' | ||
19 | import './shared/settings/settings-menu-button' | ||
20 | import './shared/settings/settings-menu-item' | ||
21 | import './shared/settings/settings-panel' | ||
22 | import './shared/settings/settings-panel-child' | ||
23 | import './shared/playlist/playlist-plugin' | ||
24 | import './shared/mobile/peertube-mobile-plugin' | ||
25 | import './shared/mobile/peertube-mobile-buttons' | ||
26 | import './shared/hotkeys/peertube-hotkeys-plugin' | ||
27 | import './shared/metrics/metrics-plugin' | ||
28 | import videojs from 'video.js' | ||
29 | import { logger } from '@root-helpers/logger' | ||
30 | import { PluginsManager } from '@root-helpers/plugins-manager' | ||
31 | import { isMobile } from '@root-helpers/web-browser' | ||
32 | import { saveAverageBandwidth } from './peertube-player-local-storage' | ||
33 | import { ManagerOptionsBuilder } from './shared/manager-options' | ||
34 | import { TranslationsManager } from './translations-manager' | ||
35 | import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode, PlayerNetworkInfo } from './types' | ||
36 | |||
37 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
38 | (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' | ||
39 | |||
40 | const CaptionsButton = videojs.getComponent('CaptionsButton') as any | ||
41 | // Change Captions to Subtitles/CC | ||
42 | CaptionsButton.prototype.controlText_ = 'Subtitles/CC' | ||
43 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
44 | CaptionsButton.prototype.label_ = ' ' | ||
45 | |||
46 | // TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged | ||
47 | const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any | ||
48 | if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { | ||
49 | PlayProgressBar.prototype.options_.children.push('timeTooltip') | ||
50 | } | ||
51 | |||
52 | export class PeertubePlayerManager { | ||
53 | private static playerElementClassName: string | ||
54 | private static playerElementAttributes: { name: string, value: string }[] = [] | ||
55 | |||
56 | private static onPlayerChange: (player: videojs.Player) => void | ||
57 | private static alreadyPlayed = false | ||
58 | private static pluginsManager: PluginsManager | ||
59 | |||
60 | private static videojsDecodeErrors = 0 | ||
61 | |||
62 | private static p2pMediaLoaderModule: any | ||
63 | |||
64 | static initState () { | ||
65 | this.alreadyPlayed = false | ||
66 | } | ||
67 | |||
68 | static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { | ||
69 | this.pluginsManager = options.pluginsManager | ||
70 | |||
71 | this.onPlayerChange = onPlayerChange | ||
72 | |||
73 | this.playerElementClassName = options.common.playerElement.className | ||
74 | |||
75 | for (const name of options.common.playerElement.getAttributeNames()) { | ||
76 | this.playerElementAttributes.push({ name, value: options.common.playerElement.getAttribute(name) }) | ||
77 | } | ||
78 | |||
79 | if (mode === 'webtorrent') await import('./shared/webtorrent/webtorrent-plugin') | ||
80 | if (mode === 'p2p-media-loader') { | ||
81 | const [ p2pMediaLoaderModule ] = await Promise.all([ | ||
82 | import('@peertube/p2p-media-loader-hlsjs'), | ||
83 | import('./shared/p2p-media-loader/p2p-media-loader-plugin') | ||
84 | ]) | ||
85 | |||
86 | this.p2pMediaLoaderModule = p2pMediaLoaderModule | ||
87 | } | ||
88 | |||
89 | await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs) | ||
90 | |||
91 | return this.buildPlayer(mode, options) | ||
92 | } | ||
93 | |||
94 | private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise<videojs.Player> { | ||
95 | const videojsOptionsBuilder = new ManagerOptionsBuilder(mode, options, this.p2pMediaLoaderModule) | ||
96 | |||
97 | const videojsOptions = await this.pluginsManager.runHook( | ||
98 | 'filter:internal.player.videojs.options.result', | ||
99 | videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed) | ||
100 | ) | ||
101 | |||
102 | const self = this | ||
103 | return new Promise(res => { | ||
104 | videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { | ||
105 | const player = this | ||
106 | |||
107 | if (!isNaN(+options.common.playbackRate)) { | ||
108 | player.playbackRate(+options.common.playbackRate) | ||
109 | } | ||
110 | |||
111 | let alreadyFallback = false | ||
112 | |||
113 | const handleError = () => { | ||
114 | if (alreadyFallback) return | ||
115 | alreadyFallback = true | ||
116 | |||
117 | if (mode === 'p2p-media-loader') { | ||
118 | self.tryToRecoverHLSError(player.error(), player, options) | ||
119 | } else { | ||
120 | self.maybeFallbackToWebTorrent(mode, player, options) | ||
121 | } | ||
122 | } | ||
123 | |||
124 | player.one('error', () => handleError()) | ||
125 | |||
126 | player.one('play', () => { | ||
127 | self.alreadyPlayed = true | ||
128 | }) | ||
129 | |||
130 | self.addContextMenu(videojsOptionsBuilder, player, options.common) | ||
131 | |||
132 | if (isMobile()) player.peertubeMobile() | ||
133 | if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive }) | ||
134 | if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden') | ||
135 | |||
136 | player.bezels() | ||
137 | |||
138 | player.stats({ | ||
139 | videoUUID: options.common.videoUUID, | ||
140 | videoIsLive: options.common.isLive, | ||
141 | mode, | ||
142 | p2pEnabled: options.common.p2pEnabled | ||
143 | }) | ||
144 | |||
145 | if (options.common.storyboard) { | ||
146 | player.storyboard(options.common.storyboard) | ||
147 | } | ||
148 | |||
149 | player.on('p2pInfo', (_, data: PlayerNetworkInfo) => { | ||
150 | if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return | ||
151 | |||
152 | saveAverageBandwidth(data.bandwidthEstimate) | ||
153 | }) | ||
154 | |||
155 | const offlineNotificationElem = document.createElement('div') | ||
156 | offlineNotificationElem.classList.add('vjs-peertube-offline-notification') | ||
157 | offlineNotificationElem.innerText = player.localize('You seem to be offline and the video may not work') | ||
158 | |||
159 | let offlineNotificationElemAdded = false | ||
160 | |||
161 | const handleOnline = () => { | ||
162 | if (!offlineNotificationElemAdded) return | ||
163 | |||
164 | player.el().removeChild(offlineNotificationElem) | ||
165 | offlineNotificationElemAdded = false | ||
166 | |||
167 | logger.info('The browser is online') | ||
168 | } | ||
169 | |||
170 | const handleOffline = () => { | ||
171 | if (offlineNotificationElemAdded) return | ||
172 | |||
173 | player.el().appendChild(offlineNotificationElem) | ||
174 | offlineNotificationElemAdded = true | ||
175 | |||
176 | logger.info('The browser is offline') | ||
177 | } | ||
178 | |||
179 | window.addEventListener('online', handleOnline) | ||
180 | window.addEventListener('offline', handleOffline) | ||
181 | |||
182 | player.on('dispose', () => { | ||
183 | window.removeEventListener('online', handleOnline) | ||
184 | window.removeEventListener('offline', handleOffline) | ||
185 | }) | ||
186 | |||
187 | return res(player) | ||
188 | }) | ||
189 | }) | ||
190 | } | ||
191 | |||
192 | private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) { | ||
193 | if (err.code === MediaError.MEDIA_ERR_DECODE) { | ||
194 | |||
195 | // Display a notification to user | ||
196 | if (this.videojsDecodeErrors === 0) { | ||
197 | options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.')) | ||
198 | } | ||
199 | |||
200 | if (this.videojsDecodeErrors === 20) { | ||
201 | this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) | ||
202 | return | ||
203 | } | ||
204 | |||
205 | logger.info('Fast forwarding HLS to recover from an error.') | ||
206 | |||
207 | this.videojsDecodeErrors++ | ||
208 | |||
209 | options.common.startTime = currentPlayer.currentTime() + 2 | ||
210 | options.common.autoplay = true | ||
211 | this.rebuildAndUpdateVideoElement(currentPlayer, options.common) | ||
212 | |||
213 | const newPlayer = await this.buildPlayer('p2p-media-loader', options) | ||
214 | this.onPlayerChange(newPlayer) | ||
215 | } else { | ||
216 | this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) | ||
217 | } | ||
218 | } | ||
219 | |||
220 | private static async maybeFallbackToWebTorrent ( | ||
221 | currentMode: PlayerMode, | ||
222 | currentPlayer: videojs.Player, | ||
223 | options: PeertubePlayerManagerOptions | ||
224 | ) { | ||
225 | if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') { | ||
226 | currentPlayer.peertube().displayFatalError() | ||
227 | return | ||
228 | } | ||
229 | |||
230 | logger.info('Fallback to webtorrent.') | ||
231 | |||
232 | this.rebuildAndUpdateVideoElement(currentPlayer, options.common) | ||
233 | |||
234 | await import('./shared/webtorrent/webtorrent-plugin') | ||
235 | |||
236 | const newPlayer = await this.buildPlayer('webtorrent', options) | ||
237 | this.onPlayerChange(newPlayer) | ||
238 | } | ||
239 | |||
240 | private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) { | ||
241 | const newVideoElement = document.createElement('video') | ||
242 | |||
243 | // Reset class | ||
244 | newVideoElement.className = this.playerElementClassName | ||
245 | |||
246 | // Reapply attributes | ||
247 | for (const { name, value } of this.playerElementAttributes) { | ||
248 | newVideoElement.setAttribute(name, value) | ||
249 | } | ||
250 | |||
251 | // VideoJS wraps our video element inside a div | ||
252 | let currentParentPlayerElement = commonOptions.playerElement.parentNode | ||
253 | // Fix on IOS, don't ask me why | ||
254 | if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode | ||
255 | |||
256 | currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) | ||
257 | |||
258 | commonOptions.playerElement = newVideoElement | ||
259 | commonOptions.onPlayerElementChange(newVideoElement) | ||
260 | |||
261 | player.dispose() | ||
262 | |||
263 | return newVideoElement | ||
264 | } | ||
265 | |||
266 | private static addContextMenu (optionsBuilder: ManagerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) { | ||
267 | const options = optionsBuilder.getContextMenuOptions(player, commonOptions) | ||
268 | |||
269 | player.contextmenuUI(options) | ||
270 | } | ||
271 | } | ||
272 | |||
273 | // ############################################################################ | ||
274 | |||
275 | export { | ||
276 | videojs | ||
277 | } | ||
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts new file mode 100644 index 000000000..a7a2b4065 --- /dev/null +++ b/client/src/assets/player/peertube-player.ts | |||
@@ -0,0 +1,522 @@ | |||
1 | import '@peertube/videojs-contextmenu' | ||
2 | import './shared/upnext/end-card' | ||
3 | import './shared/upnext/upnext-plugin' | ||
4 | import './shared/stats/stats-card' | ||
5 | import './shared/stats/stats-plugin' | ||
6 | import './shared/bezels/bezels-plugin' | ||
7 | import './shared/peertube/peertube-plugin' | ||
8 | import './shared/resolutions/peertube-resolutions-plugin' | ||
9 | import './shared/control-bar/storyboard-plugin' | ||
10 | import './shared/control-bar/next-previous-video-button' | ||
11 | import './shared/control-bar/p2p-info-button' | ||
12 | import './shared/control-bar/peertube-link-button' | ||
13 | import './shared/control-bar/theater-button' | ||
14 | import './shared/control-bar/peertube-live-display' | ||
15 | import './shared/settings/resolution-menu-button' | ||
16 | import './shared/settings/resolution-menu-item' | ||
17 | import './shared/settings/settings-dialog' | ||
18 | import './shared/settings/settings-menu-button' | ||
19 | import './shared/settings/settings-menu-item' | ||
20 | import './shared/settings/settings-panel' | ||
21 | import './shared/settings/settings-panel-child' | ||
22 | import './shared/playlist/playlist-plugin' | ||
23 | import './shared/mobile/peertube-mobile-plugin' | ||
24 | import './shared/mobile/peertube-mobile-buttons' | ||
25 | import './shared/hotkeys/peertube-hotkeys-plugin' | ||
26 | import './shared/metrics/metrics-plugin' | ||
27 | import videojs, { VideoJsPlayer } from 'video.js' | ||
28 | import { logger } from '@root-helpers/logger' | ||
29 | import { PluginsManager } from '@root-helpers/plugins-manager' | ||
30 | import { copyToClipboard } from '@root-helpers/utils' | ||
31 | import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' | ||
32 | import { isMobile } from '@root-helpers/web-browser' | ||
33 | import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@shared/core-utils' | ||
34 | import { saveAverageBandwidth } from './peertube-player-local-storage' | ||
35 | import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder' | ||
36 | import { TranslationsManager } from './translations-manager' | ||
37 | import { PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types' | ||
38 | |||
39 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
40 | (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' | ||
41 | |||
42 | const CaptionsButton = videojs.getComponent('CaptionsButton') as any | ||
43 | // Change Captions to Subtitles/CC | ||
44 | CaptionsButton.prototype.controlText_ = 'Subtitles/CC' | ||
45 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
46 | CaptionsButton.prototype.label_ = ' ' | ||
47 | |||
48 | // TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged | ||
49 | const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any | ||
50 | if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { | ||
51 | PlayProgressBar.prototype.options_.children.push('timeTooltip') | ||
52 | } | ||
53 | |||
54 | export class PeerTubePlayer { | ||
55 | private pluginsManager: PluginsManager | ||
56 | |||
57 | private videojsDecodeErrors = 0 | ||
58 | |||
59 | private p2pMediaLoaderModule: any | ||
60 | |||
61 | private player: VideoJsPlayer | ||
62 | |||
63 | private currentLoadOptions: PeerTubePlayerLoadOptions | ||
64 | |||
65 | private moduleLoaded = { | ||
66 | webVideo: false, | ||
67 | p2pMediaLoader: false | ||
68 | } | ||
69 | |||
70 | constructor (private options: PeerTubePlayerContructorOptions) { | ||
71 | this.pluginsManager = options.pluginsManager | ||
72 | } | ||
73 | |||
74 | unload () { | ||
75 | if (!this.player) return | ||
76 | |||
77 | this.disposeDynamicPluginsIfNeeded() | ||
78 | |||
79 | this.player.reset() | ||
80 | } | ||
81 | |||
82 | async load (loadOptions: PeerTubePlayerLoadOptions) { | ||
83 | this.currentLoadOptions = loadOptions | ||
84 | |||
85 | this.setPoster('') | ||
86 | |||
87 | this.disposeDynamicPluginsIfNeeded() | ||
88 | |||
89 | await this.lazyLoadModulesIfNeeded() | ||
90 | await this.buildPlayerIfNeeded() | ||
91 | |||
92 | if (this.currentLoadOptions.mode === 'p2p-media-loader') { | ||
93 | await this.loadP2PMediaLoader() | ||
94 | } else { | ||
95 | this.loadWebVideo() | ||
96 | } | ||
97 | |||
98 | this.loadDynamicPlugins() | ||
99 | |||
100 | if (this.options.controlBar === false) this.player.controlBar.hide() | ||
101 | else this.player.controlBar.show() | ||
102 | |||
103 | this.player.autoplay(this.getAutoPlayValue(this.currentLoadOptions.autoplay)) | ||
104 | |||
105 | this.player.trigger('video-change') | ||
106 | } | ||
107 | |||
108 | getPlayer () { | ||
109 | return this.player | ||
110 | } | ||
111 | |||
112 | destroy () { | ||
113 | if (this.player) this.player.dispose() | ||
114 | } | ||
115 | |||
116 | setPoster (url: string) { | ||
117 | this.player?.poster(url) | ||
118 | this.options.playerElement().poster = url | ||
119 | } | ||
120 | |||
121 | enable () { | ||
122 | if (!this.player) return | ||
123 | |||
124 | (this.player.el() as HTMLElement).style.pointerEvents = 'auto' | ||
125 | } | ||
126 | |||
127 | disable () { | ||
128 | if (!this.player) return | ||
129 | |||
130 | if (this.player.isFullscreen()) { | ||
131 | this.player.exitFullscreen() | ||
132 | } | ||
133 | |||
134 | // Disable player | ||
135 | this.player.hasStarted(false) | ||
136 | this.player.removeClass('vjs-has-autoplay') | ||
137 | this.player.bigPlayButton.hide(); | ||
138 | |||
139 | (this.player.el() as HTMLElement).style.pointerEvents = 'none' | ||
140 | } | ||
141 | |||
142 | private async loadP2PMediaLoader () { | ||
143 | const hlsOptionsBuilder = new HLSOptionsBuilder({ | ||
144 | ...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]), | ||
145 | ...pick(this.currentLoadOptions, [ | ||
146 | 'videoPassword', | ||
147 | 'requiresUserAuth', | ||
148 | 'videoFileToken', | ||
149 | 'requiresPassword', | ||
150 | 'isLive', | ||
151 | 'p2pEnabled', | ||
152 | 'liveOptions', | ||
153 | 'hls' | ||
154 | ]) | ||
155 | }, this.p2pMediaLoaderModule) | ||
156 | |||
157 | const { hlsjs, p2pMediaLoader } = await hlsOptionsBuilder.getPluginOptions() | ||
158 | |||
159 | this.player.hlsjs(hlsjs) | ||
160 | this.player.p2pMediaLoader(p2pMediaLoader) | ||
161 | } | ||
162 | |||
163 | private loadWebVideo () { | ||
164 | const webVideoOptionsBuilder = new WebVideoOptionsBuilder(pick(this.currentLoadOptions, [ | ||
165 | 'videoFileToken', | ||
166 | 'webVideo', | ||
167 | 'hls', | ||
168 | 'startTime' | ||
169 | ])) | ||
170 | |||
171 | this.player.webVideo(webVideoOptionsBuilder.getPluginOptions()) | ||
172 | } | ||
173 | |||
174 | private async buildPlayerIfNeeded () { | ||
175 | if (this.player) return | ||
176 | |||
177 | await TranslationsManager.loadLocaleInVideoJS(this.options.serverUrl, this.options.language, videojs) | ||
178 | |||
179 | const videojsOptions = await this.pluginsManager.runHook( | ||
180 | 'filter:internal.player.videojs.options.result', | ||
181 | this.getVideojsOptions() | ||
182 | ) | ||
183 | |||
184 | this.player = videojs(this.options.playerElement(), videojsOptions) | ||
185 | |||
186 | this.player.ready(() => { | ||
187 | if (!isNaN(+this.options.playbackRate)) { | ||
188 | this.player.playbackRate(+this.options.playbackRate) | ||
189 | } | ||
190 | |||
191 | let alreadyFallback = false | ||
192 | |||
193 | const handleError = () => { | ||
194 | if (alreadyFallback) return | ||
195 | alreadyFallback = true | ||
196 | |||
197 | if (this.currentLoadOptions.mode === 'p2p-media-loader') { | ||
198 | this.tryToRecoverHLSError(this.player.error()) | ||
199 | } else { | ||
200 | this.maybeFallbackToWebVideo() | ||
201 | } | ||
202 | } | ||
203 | |||
204 | this.player.one('error', () => handleError()) | ||
205 | |||
206 | this.player.on('p2p-info', (_, data: PlayerNetworkInfo) => { | ||
207 | if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return | ||
208 | |||
209 | saveAverageBandwidth(data.bandwidthEstimate) | ||
210 | }) | ||
211 | |||
212 | this.player.contextmenuUI(this.getContextMenuOptions()) | ||
213 | |||
214 | this.displayNotificationWhenOffline() | ||
215 | }) | ||
216 | } | ||
217 | |||
218 | private disposeDynamicPluginsIfNeeded () { | ||
219 | if (!this.player) return | ||
220 | |||
221 | if (this.player.usingPlugin('peertubeMobile')) this.player.peertubeMobile().dispose() | ||
222 | if (this.player.usingPlugin('peerTubeHotkeysPlugin')) this.player.peerTubeHotkeysPlugin().dispose() | ||
223 | if (this.player.usingPlugin('playlist')) this.player.playlist().dispose() | ||
224 | if (this.player.usingPlugin('bezels')) this.player.bezels().dispose() | ||
225 | if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() | ||
226 | if (this.player.usingPlugin('stats')) this.player.stats().dispose() | ||
227 | if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() | ||
228 | |||
229 | if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() | ||
230 | |||
231 | if (this.player.usingPlugin('p2pMediaLoader')) this.player.p2pMediaLoader().dispose() | ||
232 | if (this.player.usingPlugin('hlsjs')) this.player.hlsjs().dispose() | ||
233 | |||
234 | if (this.player.usingPlugin('webVideo')) this.player.webVideo().dispose() | ||
235 | } | ||
236 | |||
237 | private loadDynamicPlugins () { | ||
238 | if (isMobile()) this.player.peertubeMobile() | ||
239 | |||
240 | this.player.bezels() | ||
241 | |||
242 | this.player.stats({ | ||
243 | videoUUID: this.currentLoadOptions.videoUUID, | ||
244 | videoIsLive: this.currentLoadOptions.isLive, | ||
245 | mode: this.currentLoadOptions.mode, | ||
246 | p2pEnabled: this.currentLoadOptions.p2pEnabled | ||
247 | }) | ||
248 | |||
249 | if (this.options.enableHotkeys === true) { | ||
250 | this.player.peerTubeHotkeysPlugin({ isLive: this.currentLoadOptions.isLive }) | ||
251 | } | ||
252 | |||
253 | if (this.currentLoadOptions.playlist) { | ||
254 | this.player.playlist(this.currentLoadOptions.playlist) | ||
255 | } | ||
256 | |||
257 | if (this.currentLoadOptions.upnext) { | ||
258 | this.player.upnext({ | ||
259 | timeout: this.currentLoadOptions.upnext.timeout, | ||
260 | |||
261 | getTitle: () => this.currentLoadOptions.nextVideo.getVideoTitle(), | ||
262 | |||
263 | next: () => this.currentLoadOptions.nextVideo.handler(), | ||
264 | isDisplayed: () => this.currentLoadOptions.nextVideo.enabled && this.currentLoadOptions.upnext.isEnabled(), | ||
265 | |||
266 | isSuspended: () => this.currentLoadOptions.upnext.isSuspended(this.player) | ||
267 | }) | ||
268 | } | ||
269 | |||
270 | if (this.currentLoadOptions.storyboard) { | ||
271 | this.player.storyboard(this.currentLoadOptions.storyboard) | ||
272 | } | ||
273 | |||
274 | if (this.currentLoadOptions.dock) { | ||
275 | this.player.peertubeDock(this.currentLoadOptions.dock) | ||
276 | } | ||
277 | } | ||
278 | |||
279 | private async lazyLoadModulesIfNeeded () { | ||
280 | if (this.currentLoadOptions.mode === 'web-video' && this.moduleLoaded.webVideo !== true) { | ||
281 | await import('./shared/web-video/web-video-plugin') | ||
282 | } | ||
283 | |||
284 | if (this.currentLoadOptions.mode === 'p2p-media-loader' && this.moduleLoaded.p2pMediaLoader !== true) { | ||
285 | const [ p2pMediaLoaderModule ] = await Promise.all([ | ||
286 | import('@peertube/p2p-media-loader-hlsjs'), | ||
287 | import('./shared/p2p-media-loader/hls-plugin'), | ||
288 | import('./shared/p2p-media-loader/p2p-media-loader-plugin') | ||
289 | ]) | ||
290 | |||
291 | this.p2pMediaLoaderModule = p2pMediaLoaderModule | ||
292 | } | ||
293 | } | ||
294 | |||
295 | private async tryToRecoverHLSError (err: any) { | ||
296 | if (err.code === MediaError.MEDIA_ERR_DECODE) { | ||
297 | |||
298 | // Display a notification to user | ||
299 | if (this.videojsDecodeErrors === 0) { | ||
300 | this.options.errorNotifier(this.player.localize('The video failed to play, will try to fast forward.')) | ||
301 | } | ||
302 | |||
303 | if (this.videojsDecodeErrors === 20) { | ||
304 | this.maybeFallbackToWebVideo() | ||
305 | return | ||
306 | } | ||
307 | |||
308 | logger.info('Fast forwarding HLS to recover from an error.') | ||
309 | |||
310 | this.videojsDecodeErrors++ | ||
311 | |||
312 | await this.load({ | ||
313 | ...this.currentLoadOptions, | ||
314 | |||
315 | mode: 'p2p-media-loader', | ||
316 | startTime: this.player.currentTime() + 2, | ||
317 | autoplay: true | ||
318 | }) | ||
319 | } else { | ||
320 | this.maybeFallbackToWebVideo() | ||
321 | } | ||
322 | } | ||
323 | |||
324 | private async maybeFallbackToWebVideo () { | ||
325 | if (this.currentLoadOptions.webVideo.videoFiles.length === 0 || this.currentLoadOptions.mode === 'web-video') { | ||
326 | this.player.peertube().displayFatalError() | ||
327 | return | ||
328 | } | ||
329 | |||
330 | logger.info('Fallback to web-video.') | ||
331 | |||
332 | await this.load({ | ||
333 | ...this.currentLoadOptions, | ||
334 | |||
335 | mode: 'web-video', | ||
336 | startTime: this.player.currentTime(), | ||
337 | autoplay: true | ||
338 | }) | ||
339 | } | ||
340 | |||
341 | getVideojsOptions (): videojs.PlayerOptions { | ||
342 | const html5 = { | ||
343 | preloadTextTracks: false | ||
344 | } | ||
345 | |||
346 | const plugins: VideoJSPluginOptions = { | ||
347 | peertube: { | ||
348 | hasAutoplay: () => this.getAutoPlayValue(this.currentLoadOptions.autoplay), | ||
349 | |||
350 | videoViewUrl: () => this.currentLoadOptions.videoViewUrl, | ||
351 | videoViewIntervalMs: this.options.videoViewIntervalMs, | ||
352 | |||
353 | authorizationHeader: this.options.authorizationHeader, | ||
354 | |||
355 | videoDuration: () => this.currentLoadOptions.duration, | ||
356 | |||
357 | startTime: () => this.currentLoadOptions.startTime, | ||
358 | stopTime: () => this.currentLoadOptions.stopTime, | ||
359 | |||
360 | videoCaptions: () => this.currentLoadOptions.videoCaptions, | ||
361 | isLive: () => this.currentLoadOptions.isLive, | ||
362 | videoUUID: () => this.currentLoadOptions.videoUUID, | ||
363 | subtitle: () => this.currentLoadOptions.subtitle | ||
364 | }, | ||
365 | metrics: { | ||
366 | mode: () => this.currentLoadOptions.mode, | ||
367 | |||
368 | metricsUrl: () => this.options.metricsUrl, | ||
369 | videoUUID: () => this.currentLoadOptions.videoUUID | ||
370 | } | ||
371 | } | ||
372 | |||
373 | const controlBarOptionsBuilder = new ControlBarOptionsBuilder({ | ||
374 | ...this.options, | ||
375 | |||
376 | videoShortUUID: () => this.currentLoadOptions.videoShortUUID, | ||
377 | p2pEnabled: () => this.currentLoadOptions.p2pEnabled, | ||
378 | |||
379 | nextVideo: () => this.currentLoadOptions.nextVideo, | ||
380 | previousVideo: () => this.currentLoadOptions.previousVideo | ||
381 | }) | ||
382 | |||
383 | const videojsOptions = { | ||
384 | html5, | ||
385 | |||
386 | // We don't use text track settings for now | ||
387 | textTrackSettings: false as any, // FIXME: typings | ||
388 | controls: this.options.controls !== undefined ? this.options.controls : true, | ||
389 | loop: this.options.loop !== undefined ? this.options.loop : false, | ||
390 | |||
391 | muted: this.options.muted !== undefined | ||
392 | ? this.options.muted | ||
393 | : undefined, // Undefined so the player knows it has to check the local storage | ||
394 | |||
395 | autoplay: this.getAutoPlayValue(this.currentLoadOptions.autoplay), | ||
396 | |||
397 | poster: this.currentLoadOptions.poster, | ||
398 | inactivityTimeout: this.options.inactivityTimeout, | ||
399 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], | ||
400 | |||
401 | plugins, | ||
402 | |||
403 | controlBar: { | ||
404 | children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings | ||
405 | }, | ||
406 | |||
407 | language: this.options.language && !isDefaultLocale(this.options.language) | ||
408 | ? this.options.language | ||
409 | : undefined | ||
410 | } | ||
411 | |||
412 | return videojsOptions | ||
413 | } | ||
414 | |||
415 | private getAutoPlayValue (autoplay: boolean): videojs.Autoplay { | ||
416 | if (autoplay !== true) return false | ||
417 | |||
418 | return this.currentLoadOptions.forceAutoplay | ||
419 | ? 'any' | ||
420 | : 'play' | ||
421 | } | ||
422 | |||
423 | private displayNotificationWhenOffline () { | ||
424 | const offlineNotificationElem = document.createElement('div') | ||
425 | offlineNotificationElem.classList.add('vjs-peertube-offline-notification') | ||
426 | offlineNotificationElem.innerText = this.player.localize('You seem to be offline and the video may not work') | ||
427 | |||
428 | let offlineNotificationElemAdded = false | ||
429 | |||
430 | const handleOnline = () => { | ||
431 | if (!offlineNotificationElemAdded) return | ||
432 | |||
433 | this.player.el().removeChild(offlineNotificationElem) | ||
434 | offlineNotificationElemAdded = false | ||
435 | |||
436 | logger.info('The browser is online') | ||
437 | } | ||
438 | |||
439 | const handleOffline = () => { | ||
440 | if (offlineNotificationElemAdded) return | ||
441 | |||
442 | this.player.el().appendChild(offlineNotificationElem) | ||
443 | offlineNotificationElemAdded = true | ||
444 | |||
445 | logger.info('The browser is offline') | ||
446 | } | ||
447 | |||
448 | window.addEventListener('online', handleOnline) | ||
449 | window.addEventListener('offline', handleOffline) | ||
450 | |||
451 | this.player.on('dispose', () => { | ||
452 | window.removeEventListener('online', handleOnline) | ||
453 | window.removeEventListener('offline', handleOffline) | ||
454 | }) | ||
455 | } | ||
456 | |||
457 | private getContextMenuOptions () { | ||
458 | |||
459 | const content = () => { | ||
460 | const self = this | ||
461 | const player = this.player | ||
462 | |||
463 | const shortUUID = self.currentLoadOptions.videoShortUUID | ||
464 | const isLoopEnabled = player.options_['loop'] | ||
465 | |||
466 | const items = [ | ||
467 | { | ||
468 | icon: 'repeat', | ||
469 | label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''), | ||
470 | listener: function () { | ||
471 | player.options_['loop'] = !isLoopEnabled | ||
472 | } | ||
473 | }, | ||
474 | { | ||
475 | label: player.localize('Copy the video URL'), | ||
476 | listener: function () { | ||
477 | copyToClipboard(buildVideoLink({ shortUUID })) | ||
478 | } | ||
479 | }, | ||
480 | { | ||
481 | label: player.localize('Copy the video URL at the current time'), | ||
482 | listener: function () { | ||
483 | const url = buildVideoLink({ shortUUID }) | ||
484 | |||
485 | copyToClipboard(decorateVideoLink({ url, startTime: player.currentTime() })) | ||
486 | } | ||
487 | }, | ||
488 | { | ||
489 | icon: 'code', | ||
490 | label: player.localize('Copy embed code'), | ||
491 | listener: () => { | ||
492 | copyToClipboard(buildVideoOrPlaylistEmbed({ | ||
493 | embedUrl: self.currentLoadOptions.embedUrl, | ||
494 | embedTitle: self.currentLoadOptions.embedTitle | ||
495 | })) | ||
496 | } | ||
497 | } | ||
498 | ] | ||
499 | |||
500 | items.push({ | ||
501 | icon: 'info', | ||
502 | label: player.localize('Stats for nerds'), | ||
503 | listener: () => { | ||
504 | player.stats().show() | ||
505 | } | ||
506 | }) | ||
507 | |||
508 | return items.map(i => ({ | ||
509 | ...i, | ||
510 | label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label | ||
511 | })) | ||
512 | } | ||
513 | |||
514 | return { content } | ||
515 | } | ||
516 | } | ||
517 | |||
518 | // ############################################################################ | ||
519 | |||
520 | export { | ||
521 | videojs | ||
522 | } | ||
diff --git a/client/src/assets/player/shared/bezels/bezels-plugin.ts b/client/src/assets/player/shared/bezels/bezels-plugin.ts index ca88bc1f9..6afb2c6a3 100644 --- a/client/src/assets/player/shared/bezels/bezels-plugin.ts +++ b/client/src/assets/player/shared/bezels/bezels-plugin.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import './pause-bezel' | 2 | import { PauseBezel } from './pause-bezel' |
3 | 3 | ||
4 | const Plugin = videojs.getPlugin('plugin') | 4 | const Plugin = videojs.getPlugin('plugin') |
5 | 5 | ||
@@ -12,7 +12,7 @@ class BezelsPlugin extends Plugin { | |||
12 | player.addClass('vjs-bezels') | 12 | player.addClass('vjs-bezels') |
13 | }) | 13 | }) |
14 | 14 | ||
15 | player.addChild('PauseBezel', options) | 15 | player.addChild(new PauseBezel(player, options)) |
16 | } | 16 | } |
17 | } | 17 | } |
18 | 18 | ||
diff --git a/client/src/assets/player/shared/bezels/pause-bezel.ts b/client/src/assets/player/shared/bezels/pause-bezel.ts index e35c39a5f..d364ad0dd 100644 --- a/client/src/assets/player/shared/bezels/pause-bezel.ts +++ b/client/src/assets/player/shared/bezels/pause-bezel.ts | |||
@@ -32,26 +32,61 @@ function getPlayBezel () { | |||
32 | } | 32 | } |
33 | 33 | ||
34 | const Component = videojs.getComponent('Component') | 34 | const Component = videojs.getComponent('Component') |
35 | class PauseBezel extends Component { | 35 | export class PauseBezel extends Component { |
36 | container: HTMLDivElement | 36 | container: HTMLDivElement |
37 | 37 | ||
38 | private firstPlayDone = false | ||
39 | private paused = false | ||
40 | |||
41 | private playerPauseHandler: () => void | ||
42 | private playerPlayHandler: () => void | ||
43 | private videoChangeHandler: () => void | ||
44 | |||
38 | constructor (player: videojs.Player, options?: videojs.ComponentOptions) { | 45 | constructor (player: videojs.Player, options?: videojs.ComponentOptions) { |
39 | super(player, options) | 46 | super(player, options) |
40 | 47 | ||
41 | // Hide bezels on mobile since we already have our mobile overlay | 48 | // Hide bezels on mobile since we already have our mobile overlay |
42 | if (isMobile()) return | 49 | if (isMobile()) return |
43 | 50 | ||
44 | player.on('pause', (_: any) => { | 51 | this.playerPauseHandler = () => { |
45 | if (player.seeking() || player.ended()) return | 52 | if (player.seeking()) return |
53 | |||
54 | this.paused = true | ||
55 | |||
56 | if (player.ended()) return | ||
57 | |||
46 | this.container.innerHTML = getPauseBezel() | 58 | this.container.innerHTML = getPauseBezel() |
47 | this.showBezel() | 59 | this.showBezel() |
48 | }) | 60 | } |
61 | |||
62 | this.playerPlayHandler = () => { | ||
63 | if (player.seeking() || !this.firstPlayDone || !this.paused) { | ||
64 | this.firstPlayDone = true | ||
65 | return | ||
66 | } | ||
67 | |||
68 | this.paused = false | ||
69 | this.firstPlayDone = true | ||
49 | 70 | ||
50 | player.on('play', (_: any) => { | ||
51 | if (player.seeking()) return | ||
52 | this.container.innerHTML = getPlayBezel() | 71 | this.container.innerHTML = getPlayBezel() |
53 | this.showBezel() | 72 | this.showBezel() |
54 | }) | 73 | } |
74 | |||
75 | this.videoChangeHandler = () => { | ||
76 | this.firstPlayDone = false | ||
77 | } | ||
78 | |||
79 | player.on('video-change', () => this.videoChangeHandler) | ||
80 | player.on('pause', this.playerPauseHandler) | ||
81 | player.on('play', this.playerPlayHandler) | ||
82 | } | ||
83 | |||
84 | dispose () { | ||
85 | if (this.playerPauseHandler) this.player().off('pause', this.playerPauseHandler) | ||
86 | if (this.playerPlayHandler) this.player().off('play', this.playerPlayHandler) | ||
87 | if (this.videoChangeHandler) this.player().off('video-change', this.videoChangeHandler) | ||
88 | |||
89 | super.dispose() | ||
55 | } | 90 | } |
56 | 91 | ||
57 | createEl () { | 92 | createEl () { |
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 24877c267..9307027f6 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts | |||
@@ -2,6 +2,5 @@ export * from './next-previous-video-button' | |||
2 | export * from './p2p-info-button' | 2 | export * from './p2p-info-button' |
3 | export * from './peertube-link-button' | 3 | export * from './peertube-link-button' |
4 | export * from './peertube-live-display' | 4 | export * from './peertube-live-display' |
5 | export * from './peertube-load-progress-bar' | ||
6 | export * from './storyboard-plugin' | 5 | export * from './storyboard-plugin' |
7 | export * from './theater-button' | 6 | export * from './theater-button' |
diff --git a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts index b7b986806..18a107f52 100644 --- a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts +++ b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts | |||
@@ -4,14 +4,18 @@ import { NextPreviousVideoButtonOptions } from '../../types' | |||
4 | const Button = videojs.getComponent('Button') | 4 | const Button = videojs.getComponent('Button') |
5 | 5 | ||
6 | class NextPreviousVideoButton extends Button { | 6 | class NextPreviousVideoButton extends Button { |
7 | private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions | 7 | options_: NextPreviousVideoButtonOptions & videojs.ComponentOptions |
8 | 8 | ||
9 | constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { | 9 | constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions & videojs.ComponentOptions) { |
10 | super(player, options as any) | 10 | super(player, options) |
11 | 11 | ||
12 | this.nextPreviousVideoButtonOptions = options | 12 | this.player().on('video-change', () => { |
13 | this.updateDisabled() | ||
14 | this.updateShowing() | ||
15 | }) | ||
13 | 16 | ||
14 | this.update() | 17 | this.updateDisabled() |
18 | this.updateShowing() | ||
15 | } | 19 | } |
16 | 20 | ||
17 | createEl () { | 21 | createEl () { |
@@ -35,15 +39,20 @@ class NextPreviousVideoButton extends Button { | |||
35 | } | 39 | } |
36 | 40 | ||
37 | handleClick () { | 41 | handleClick () { |
38 | this.nextPreviousVideoButtonOptions.handler() | 42 | this.options_.handler() |
39 | } | 43 | } |
40 | 44 | ||
41 | update () { | 45 | updateDisabled () { |
42 | const disabled = this.nextPreviousVideoButtonOptions.isDisabled() | 46 | const disabled = this.options_.isDisabled() |
43 | 47 | ||
44 | if (disabled) this.addClass('vjs-disabled') | 48 | if (disabled) this.addClass('vjs-disabled') |
45 | else this.removeClass('vjs-disabled') | 49 | else this.removeClass('vjs-disabled') |
46 | } | 50 | } |
51 | |||
52 | updateShowing () { | ||
53 | if (this.options_.isDisplayed()) this.show() | ||
54 | else this.hide() | ||
55 | } | ||
47 | } | 56 | } |
48 | 57 | ||
49 | videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) | 58 | videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) |
diff --git a/client/src/assets/player/shared/control-bar/p2p-info-button.ts b/client/src/assets/player/shared/control-bar/p2p-info-button.ts index 1979654ad..4177b3280 100644 --- a/client/src/assets/player/shared/control-bar/p2p-info-button.ts +++ b/client/src/assets/player/shared/control-bar/p2p-info-button.ts | |||
@@ -1,71 +1,44 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' | 2 | import { PlayerNetworkInfo } from '../../types' |
3 | import { bytes } from '../common' | 3 | import { bytes } from '../common' |
4 | 4 | ||
5 | const Button = videojs.getComponent('Button') | 5 | const Button = videojs.getComponent('Button') |
6 | class P2pInfoButton extends Button { | 6 | class P2PInfoButton extends Button { |
7 | 7 | el_: HTMLElement | |
8 | constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) { | ||
9 | super(player, options as any) | ||
10 | } | ||
11 | 8 | ||
12 | createEl () { | 9 | createEl () { |
13 | const div = videojs.dom.createEl('div', { | 10 | const div = videojs.dom.createEl('div', { className: 'vjs-peertube' }) |
14 | className: 'vjs-peertube' | 11 | const subDivP2P = videojs.dom.createEl('div', { |
15 | }) | ||
16 | const subDivWebtorrent = videojs.dom.createEl('div', { | ||
17 | className: 'vjs-peertube-hidden' // Hide the stats before we get the info | 12 | className: 'vjs-peertube-hidden' // Hide the stats before we get the info |
18 | }) as HTMLDivElement | 13 | }) as HTMLDivElement |
19 | div.appendChild(subDivWebtorrent) | 14 | div.appendChild(subDivP2P) |
20 | 15 | ||
21 | // Stop here if P2P is not enabled | 16 | const downloadIcon = videojs.dom.createEl('span', { className: 'icon icon-download' }) |
22 | const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled | 17 | subDivP2P.appendChild(downloadIcon) |
23 | if (!p2pEnabled) return div as HTMLButtonElement | ||
24 | 18 | ||
25 | const downloadIcon = videojs.dom.createEl('span', { | 19 | const downloadSpeedText = videojs.dom.createEl('span', { className: 'download-speed-text' }) |
26 | className: 'icon icon-download' | 20 | const downloadSpeedNumber = videojs.dom.createEl('span', { className: 'download-speed-number' }) |
27 | }) | ||
28 | subDivWebtorrent.appendChild(downloadIcon) | ||
29 | |||
30 | const downloadSpeedText = videojs.dom.createEl('span', { | ||
31 | className: 'download-speed-text' | ||
32 | }) | ||
33 | const downloadSpeedNumber = videojs.dom.createEl('span', { | ||
34 | className: 'download-speed-number' | ||
35 | }) | ||
36 | const downloadSpeedUnit = videojs.dom.createEl('span') | 21 | const downloadSpeedUnit = videojs.dom.createEl('span') |
37 | downloadSpeedText.appendChild(downloadSpeedNumber) | 22 | downloadSpeedText.appendChild(downloadSpeedNumber) |
38 | downloadSpeedText.appendChild(downloadSpeedUnit) | 23 | downloadSpeedText.appendChild(downloadSpeedUnit) |
39 | subDivWebtorrent.appendChild(downloadSpeedText) | 24 | subDivP2P.appendChild(downloadSpeedText) |
40 | 25 | ||
41 | const uploadIcon = videojs.dom.createEl('span', { | 26 | const uploadIcon = videojs.dom.createEl('span', { className: 'icon icon-upload' }) |
42 | className: 'icon icon-upload' | 27 | subDivP2P.appendChild(uploadIcon) |
43 | }) | ||
44 | subDivWebtorrent.appendChild(uploadIcon) | ||
45 | 28 | ||
46 | const uploadSpeedText = videojs.dom.createEl('span', { | 29 | const uploadSpeedText = videojs.dom.createEl('span', { className: 'upload-speed-text' }) |
47 | className: 'upload-speed-text' | 30 | const uploadSpeedNumber = videojs.dom.createEl('span', { className: 'upload-speed-number' }) |
48 | }) | ||
49 | const uploadSpeedNumber = videojs.dom.createEl('span', { | ||
50 | className: 'upload-speed-number' | ||
51 | }) | ||
52 | const uploadSpeedUnit = videojs.dom.createEl('span') | 31 | const uploadSpeedUnit = videojs.dom.createEl('span') |
53 | uploadSpeedText.appendChild(uploadSpeedNumber) | 32 | uploadSpeedText.appendChild(uploadSpeedNumber) |
54 | uploadSpeedText.appendChild(uploadSpeedUnit) | 33 | uploadSpeedText.appendChild(uploadSpeedUnit) |
55 | subDivWebtorrent.appendChild(uploadSpeedText) | 34 | subDivP2P.appendChild(uploadSpeedText) |
56 | 35 | ||
57 | const peersText = videojs.dom.createEl('span', { | 36 | const peersText = videojs.dom.createEl('span', { className: 'peers-text' }) |
58 | className: 'peers-text' | 37 | const peersNumber = videojs.dom.createEl('span', { className: 'peers-number' }) |
59 | }) | 38 | subDivP2P.appendChild(peersNumber) |
60 | const peersNumber = videojs.dom.createEl('span', { | 39 | subDivP2P.appendChild(peersText) |
61 | className: 'peers-number' | ||
62 | }) | ||
63 | subDivWebtorrent.appendChild(peersNumber) | ||
64 | subDivWebtorrent.appendChild(peersText) | ||
65 | 40 | ||
66 | const subDivHttp = videojs.dom.createEl('div', { | 41 | const subDivHttp = videojs.dom.createEl('div', { className: 'vjs-peertube-hidden' }) as HTMLElement |
67 | className: 'vjs-peertube-hidden' | ||
68 | }) | ||
69 | const subDivHttpText = videojs.dom.createEl('span', { | 42 | const subDivHttpText = videojs.dom.createEl('span', { |
70 | className: 'http-fallback', | 43 | className: 'http-fallback', |
71 | textContent: 'HTTP' | 44 | textContent: 'HTTP' |
@@ -74,14 +47,9 @@ class P2pInfoButton extends Button { | |||
74 | subDivHttp.appendChild(subDivHttpText) | 47 | subDivHttp.appendChild(subDivHttpText) |
75 | div.appendChild(subDivHttp) | 48 | div.appendChild(subDivHttp) |
76 | 49 | ||
77 | this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { | 50 | this.player_.on('p2p-info', (_event: any, data: PlayerNetworkInfo) => { |
78 | // We are in HTTP fallback | 51 | subDivP2P.className = 'vjs-peertube-displayed' |
79 | if (!data) { | 52 | subDivHttp.className = 'vjs-peertube-hidden' |
80 | subDivHttp.className = 'vjs-peertube-displayed' | ||
81 | subDivWebtorrent.className = 'vjs-peertube-hidden' | ||
82 | |||
83 | return | ||
84 | } | ||
85 | 53 | ||
86 | const p2pStats = data.p2p | 54 | const p2pStats = data.p2p |
87 | const httpStats = data.http | 55 | const httpStats = data.http |
@@ -92,17 +60,17 @@ class P2pInfoButton extends Button { | |||
92 | const totalUploaded = bytes(p2pStats.uploaded) | 60 | const totalUploaded = bytes(p2pStats.uploaded) |
93 | const numPeers = p2pStats.numPeers | 61 | const numPeers = p2pStats.numPeers |
94 | 62 | ||
95 | subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' | 63 | subDivP2P.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' |
96 | 64 | ||
97 | if (data.source === 'p2p-media-loader') { | 65 | if (data.source === 'p2p-media-loader') { |
98 | const downloadedFromServer = bytes(httpStats.downloaded).join(' ') | 66 | const downloadedFromServer = bytes(httpStats.downloaded).join(' ') |
99 | const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') | 67 | const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') |
100 | 68 | ||
101 | subDivWebtorrent.title += | 69 | subDivP2P.title += |
102 | ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + | 70 | ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + |
103 | ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' | 71 | ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' |
104 | } | 72 | } |
105 | subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') | 73 | subDivP2P.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') |
106 | 74 | ||
107 | downloadSpeedNumber.textContent = downloadSpeed[0] | 75 | downloadSpeedNumber.textContent = downloadSpeed[0] |
108 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] | 76 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] |
@@ -114,11 +82,24 @@ class P2pInfoButton extends Button { | |||
114 | peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) | 82 | peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) |
115 | 83 | ||
116 | subDivHttp.className = 'vjs-peertube-hidden' | 84 | subDivHttp.className = 'vjs-peertube-hidden' |
117 | subDivWebtorrent.className = 'vjs-peertube-displayed' | 85 | subDivP2P.className = 'vjs-peertube-displayed' |
86 | }) | ||
87 | |||
88 | this.player_.on('http-info', (_event, data: PlayerNetworkInfo) => { | ||
89 | // We are in HTTP fallback | ||
90 | subDivHttp.className = 'vjs-peertube-displayed' | ||
91 | subDivP2P.className = 'vjs-peertube-hidden' | ||
92 | |||
93 | subDivHttp.title = this.player().localize('Total downloaded: ') + bytes(data.http.downloaded).join(' ') | ||
94 | }) | ||
95 | |||
96 | this.player_.on('video-change', () => { | ||
97 | subDivP2P.className = 'vjs-peertube-hidden' | ||
98 | subDivHttp.className = 'vjs-peertube-hidden' | ||
118 | }) | 99 | }) |
119 | 100 | ||
120 | return div as HTMLButtonElement | 101 | return div as HTMLButtonElement |
121 | } | 102 | } |
122 | } | 103 | } |
123 | 104 | ||
124 | videojs.registerComponent('P2PInfoButton', P2pInfoButton) | 105 | videojs.registerComponent('P2PInfoButton', P2PInfoButton) |
diff --git a/client/src/assets/player/shared/control-bar/peertube-link-button.ts b/client/src/assets/player/shared/control-bar/peertube-link-button.ts index 45d7ac42f..8242b9cea 100644 --- a/client/src/assets/player/shared/control-bar/peertube-link-button.ts +++ b/client/src/assets/player/shared/control-bar/peertube-link-button.ts | |||
@@ -3,37 +3,58 @@ import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' | |||
3 | import { PeerTubeLinkButtonOptions } from '../../types' | 3 | import { PeerTubeLinkButtonOptions } from '../../types' |
4 | 4 | ||
5 | const Component = videojs.getComponent('Component') | 5 | const Component = videojs.getComponent('Component') |
6 | |||
6 | class PeerTubeLinkButton extends Component { | 7 | class PeerTubeLinkButton extends Component { |
8 | private mouseEnterHandler: () => void | ||
9 | private clickHandler: () => void | ||
7 | 10 | ||
8 | constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { | 11 | options_: PeerTubeLinkButtonOptions & videojs.ComponentOptions |
9 | super(player, options as any) | ||
10 | } | ||
11 | 12 | ||
12 | createEl () { | 13 | constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions & videojs.ComponentOptions) { |
13 | return this.buildElement() | 14 | super(player, options) |
15 | |||
16 | this.updateShowing() | ||
17 | this.player().on('video-change', () => this.updateShowing()) | ||
14 | } | 18 | } |
15 | 19 | ||
16 | updateHref () { | 20 | dispose () { |
17 | this.el().setAttribute('href', this.buildLink()) | 21 | if (this.el()) return |
22 | |||
23 | this.el().removeEventListener('mouseenter', this.mouseEnterHandler) | ||
24 | this.el().removeEventListener('click', this.clickHandler) | ||
25 | |||
26 | super.dispose() | ||
18 | } | 27 | } |
19 | 28 | ||
20 | private buildElement () { | 29 | createEl () { |
21 | const el = videojs.dom.createEl('a', { | 30 | const el = videojs.dom.createEl('a', { |
22 | href: this.buildLink(), | 31 | href: this.buildLink(), |
23 | innerHTML: (this.options_ as PeerTubeLinkButtonOptions).instanceName, | 32 | innerHTML: this.options_.instanceName, |
24 | title: this.player().localize('Video page (new window)'), | 33 | title: this.player().localize('Video page (new window)'), |
25 | className: 'vjs-peertube-link', | 34 | className: 'vjs-peertube-link', |
26 | target: '_blank' | 35 | target: '_blank' |
27 | }) | 36 | }) |
28 | 37 | ||
29 | el.addEventListener('mouseenter', () => this.updateHref()) | 38 | this.mouseEnterHandler = () => this.updateHref() |
30 | el.addEventListener('click', () => this.player().pause()) | 39 | this.clickHandler = () => this.player().pause() |
40 | |||
41 | el.addEventListener('mouseenter', this.mouseEnterHandler) | ||
42 | el.addEventListener('click', this.clickHandler) | ||
43 | |||
44 | return el | ||
45 | } | ||
46 | |||
47 | updateShowing () { | ||
48 | if (this.options_.isDisplayed()) this.show() | ||
49 | else this.hide() | ||
50 | } | ||
31 | 51 | ||
32 | return el as HTMLButtonElement | 52 | updateHref () { |
53 | this.el().setAttribute('href', this.buildLink()) | ||
33 | } | 54 | } |
34 | 55 | ||
35 | private buildLink () { | 56 | private buildLink () { |
36 | const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID }) | 57 | const url = buildVideoLink({ shortUUID: this.options_.shortUUID() }) |
37 | 58 | ||
38 | return decorateVideoLink({ url, startTime: this.player().currentTime() }) | 59 | return decorateVideoLink({ url, startTime: this.player().currentTime() }) |
39 | } | 60 | } |
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts index 649eb0b00..f9f6bf12f 100644 --- a/client/src/assets/player/shared/control-bar/peertube-live-display.ts +++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts | |||
@@ -13,7 +13,6 @@ class PeerTubeLiveDisplay extends ClickableComponent { | |||
13 | 13 | ||
14 | this.interval = this.setInterval(() => this.updateClass(), 1000) | 14 | this.interval = this.setInterval(() => this.updateClass(), 1000) |
15 | 15 | ||
16 | this.show() | ||
17 | this.updateSync(true) | 16 | this.updateSync(true) |
18 | } | 17 | } |
19 | 18 | ||
@@ -30,7 +29,7 @@ class PeerTubeLiveDisplay extends ClickableComponent { | |||
30 | 29 | ||
31 | createEl () { | 30 | createEl () { |
32 | const el = super.createEl('div', { | 31 | const el = super.createEl('div', { |
33 | className: 'vjs-live-control vjs-control' | 32 | className: 'vjs-pt-live-control vjs-control' |
34 | }) | 33 | }) |
35 | 34 | ||
36 | this.contentEl_ = videojs.dom.createEl('div', { | 35 | this.contentEl_ = videojs.dom.createEl('div', { |
@@ -83,10 +82,9 @@ class PeerTubeLiveDisplay extends ClickableComponent { | |||
83 | } | 82 | } |
84 | 83 | ||
85 | private getHLSJS () { | 84 | private getHLSJS () { |
86 | const p2pMediaLoader = this.player()?.p2pMediaLoader | 85 | if (!this.player()?.usingPlugin('p2pMediaLoader')) return |
87 | if (!p2pMediaLoader) return undefined | ||
88 | 86 | ||
89 | return p2pMediaLoader().getHLSJS() | 87 | return this.player().p2pMediaLoader().getHLSJS() |
90 | } | 88 | } |
91 | } | 89 | } |
92 | 90 | ||
diff --git a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts deleted file mode 100644 index 623e70eb2..000000000 --- a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts +++ /dev/null | |||
@@ -1,33 +0,0 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | class PeerTubeLoadProgressBar extends Component { | ||
6 | |||
7 | constructor (player: videojs.Player, options?: videojs.ComponentOptions) { | ||
8 | super(player, options) | ||
9 | |||
10 | this.on(player, 'progress', this.update) | ||
11 | } | ||
12 | |||
13 | createEl () { | ||
14 | return super.createEl('div', { | ||
15 | className: 'vjs-load-progress', | ||
16 | innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>` | ||
17 | }) | ||
18 | } | ||
19 | |||
20 | dispose () { | ||
21 | super.dispose() | ||
22 | } | ||
23 | |||
24 | update () { | ||
25 | const torrent = this.player().webtorrent().getTorrent() | ||
26 | if (!torrent) return | ||
27 | |||
28 | (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%' | ||
29 | } | ||
30 | |||
31 | } | ||
32 | |||
33 | Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) | ||
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 81ab60842..80c69b5f2 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts | |||
@@ -24,6 +24,8 @@ class StoryboardPlugin extends Plugin { | |||
24 | 24 | ||
25 | private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip | 25 | private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip |
26 | 26 | ||
27 | private onReadyOrLoadstartHandler: (event: { type: 'ready' }) => void | ||
28 | |||
27 | constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) { | 29 | constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) { |
28 | super(player, options) | 30 | super(player, options) |
29 | 31 | ||
@@ -54,7 +56,7 @@ class StoryboardPlugin extends Plugin { | |||
54 | this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement | 56 | this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement |
55 | this.seekBar?.el()?.appendChild(this.spritePlaceholder) | 57 | this.seekBar?.el()?.appendChild(this.spritePlaceholder) |
56 | 58 | ||
57 | this.player.on([ 'ready', 'loadstart' ], event => { | 59 | this.onReadyOrLoadstartHandler = event => { |
58 | if (event.type !== 'ready') { | 60 | if (event.type !== 'ready') { |
59 | const spriteSource = this.player.currentSources().find(source => { | 61 | const spriteSource = this.player.currentSources().find(source => { |
60 | return Object.prototype.hasOwnProperty.call(source, 'storyboard') | 62 | return Object.prototype.hasOwnProperty.call(source, 'storyboard') |
@@ -72,7 +74,18 @@ class StoryboardPlugin extends Plugin { | |||
72 | this.cached = !!this.sprites[this.url] | 74 | this.cached = !!this.sprites[this.url] |
73 | 75 | ||
74 | this.load() | 76 | this.load() |
75 | }) | 77 | } |
78 | |||
79 | this.player.on([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler) | ||
80 | } | ||
81 | |||
82 | dispose () { | ||
83 | if (this.onReadyOrLoadstartHandler) this.player.off([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler) | ||
84 | if (this.progress) this.progress.off([ 'mousemove', 'touchmove' ], this.boundedHijackMouseTooltip) | ||
85 | |||
86 | this.seekBar?.el()?.removeChild(this.spritePlaceholder) | ||
87 | |||
88 | super.dispose() | ||
76 | } | 89 | } |
77 | 90 | ||
78 | private load () { | 91 | private load () { |
diff --git a/client/src/assets/player/shared/control-bar/theater-button.ts b/client/src/assets/player/shared/control-bar/theater-button.ts index 56c349d6b..a5feb56ee 100644 --- a/client/src/assets/player/shared/control-bar/theater-button.ts +++ b/client/src/assets/player/shared/control-bar/theater-button.ts | |||
@@ -1,14 +1,19 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' | 2 | import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' |
3 | import { TheaterButtonOptions } from '../../types' | ||
3 | 4 | ||
4 | const Button = videojs.getComponent('Button') | 5 | const Button = videojs.getComponent('Button') |
5 | class TheaterButton extends Button { | 6 | class TheaterButton extends Button { |
6 | 7 | ||
7 | private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' | 8 | private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' |
8 | 9 | ||
9 | constructor (player: videojs.Player, options: videojs.ComponentOptions) { | 10 | private theaterButtonOptions: TheaterButtonOptions |
11 | |||
12 | constructor (player: videojs.Player, options: TheaterButtonOptions & videojs.ComponentOptions) { | ||
10 | super(player, options) | 13 | super(player, options) |
11 | 14 | ||
15 | this.theaterButtonOptions = options | ||
16 | |||
12 | const enabled = getStoredTheater() | 17 | const enabled = getStoredTheater() |
13 | if (enabled === true) { | 18 | if (enabled === true) { |
14 | this.player().addClass(TheaterButton.THEATER_MODE_CLASS) | 19 | this.player().addClass(TheaterButton.THEATER_MODE_CLASS) |
@@ -19,6 +24,9 @@ class TheaterButton extends Button { | |||
19 | this.controlText('Theater mode') | 24 | this.controlText('Theater mode') |
20 | 25 | ||
21 | this.player().theaterEnabled = enabled | 26 | this.player().theaterEnabled = enabled |
27 | |||
28 | this.updateShowing() | ||
29 | this.player().on('video-change', () => this.updateShowing()) | ||
22 | } | 30 | } |
23 | 31 | ||
24 | buildCSSClass () { | 32 | buildCSSClass () { |
@@ -36,7 +44,7 @@ class TheaterButton extends Button { | |||
36 | 44 | ||
37 | saveTheaterInStore(theaterEnabled) | 45 | saveTheaterInStore(theaterEnabled) |
38 | 46 | ||
39 | this.player_.trigger('theaterChange', theaterEnabled) | 47 | this.player_.trigger('theater-change', theaterEnabled) |
40 | } | 48 | } |
41 | 49 | ||
42 | handleClick () { | 50 | handleClick () { |
@@ -48,6 +56,11 @@ class TheaterButton extends Button { | |||
48 | private isTheaterEnabled () { | 56 | private isTheaterEnabled () { |
49 | return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) | 57 | return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) |
50 | } | 58 | } |
59 | |||
60 | private updateShowing () { | ||
61 | if (this.theaterButtonOptions.isDisplayed()) this.show() | ||
62 | else this.hide() | ||
63 | } | ||
51 | } | 64 | } |
52 | 65 | ||
53 | videojs.registerComponent('TheaterButton', TheaterButton) | 66 | videojs.registerComponent('TheaterButton', TheaterButton) |
diff --git a/client/src/assets/player/shared/dock/peertube-dock-component.ts b/client/src/assets/player/shared/dock/peertube-dock-component.ts index 183c7a00f..c13ca647b 100644 --- a/client/src/assets/player/shared/dock/peertube-dock-component.ts +++ b/client/src/assets/player/shared/dock/peertube-dock-component.ts | |||
@@ -10,17 +10,20 @@ export type PeerTubeDockComponentOptions = { | |||
10 | 10 | ||
11 | class PeerTubeDockComponent extends Component { | 11 | class PeerTubeDockComponent extends Component { |
12 | 12 | ||
13 | createEl () { | 13 | options_: videojs.ComponentOptions & PeerTubeDockComponentOptions |
14 | const options = this.options_ as PeerTubeDockComponentOptions | ||
15 | 14 | ||
16 | const el = super.createEl('div', { | 15 | // eslint-disable-next-line @typescript-eslint/no-useless-constructor |
17 | className: 'peertube-dock' | 16 | constructor (player: videojs.Player, options: videojs.ComponentOptions & PeerTubeDockComponentOptions) { |
18 | }) | 17 | super(player, options) |
18 | } | ||
19 | |||
20 | createEl () { | ||
21 | const el = super.createEl('div', { className: 'peertube-dock' }) | ||
19 | 22 | ||
20 | if (options.avatarUrl) { | 23 | if (this.options_.avatarUrl) { |
21 | const avatar = videojs.dom.createEl('img', { | 24 | const avatar = videojs.dom.createEl('img', { |
22 | className: 'peertube-dock-avatar', | 25 | className: 'peertube-dock-avatar', |
23 | src: options.avatarUrl | 26 | src: this.options_.avatarUrl |
24 | }) | 27 | }) |
25 | 28 | ||
26 | el.appendChild(avatar) | 29 | el.appendChild(avatar) |
@@ -30,27 +33,27 @@ class PeerTubeDockComponent extends Component { | |||
30 | className: 'peertube-dock-title-description' | 33 | className: 'peertube-dock-title-description' |
31 | }) | 34 | }) |
32 | 35 | ||
33 | if (options.title) { | 36 | if (this.options_.title) { |
34 | const title = videojs.dom.createEl('div', { | 37 | const title = videojs.dom.createEl('div', { |
35 | className: 'peertube-dock-title', | 38 | className: 'peertube-dock-title', |
36 | title: options.title, | 39 | title: this.options_.title, |
37 | innerHTML: options.title | 40 | innerHTML: this.options_.title |
38 | }) | 41 | }) |
39 | 42 | ||
40 | elWrapperTitleDescription.appendChild(title) | 43 | elWrapperTitleDescription.appendChild(title) |
41 | } | 44 | } |
42 | 45 | ||
43 | if (options.description) { | 46 | if (this.options_.description) { |
44 | const description = videojs.dom.createEl('div', { | 47 | const description = videojs.dom.createEl('div', { |
45 | className: 'peertube-dock-description', | 48 | className: 'peertube-dock-description', |
46 | title: options.description, | 49 | title: this.options_.description, |
47 | innerHTML: options.description | 50 | innerHTML: this.options_.description |
48 | }) | 51 | }) |
49 | 52 | ||
50 | elWrapperTitleDescription.appendChild(description) | 53 | elWrapperTitleDescription.appendChild(description) |
51 | } | 54 | } |
52 | 55 | ||
53 | if (options.title || options.description) { | 56 | if (this.options_.title || this.options_.description) { |
54 | el.appendChild(elWrapperTitleDescription) | 57 | el.appendChild(elWrapperTitleDescription) |
55 | } | 58 | } |
56 | 59 | ||
diff --git a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts index 245981692..fc71a8c4b 100644 --- a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts +++ b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts | |||
@@ -10,14 +10,25 @@ export type PeerTubeDockPluginOptions = { | |||
10 | } | 10 | } |
11 | 11 | ||
12 | class PeerTubeDockPlugin extends Plugin { | 12 | class PeerTubeDockPlugin extends Plugin { |
13 | private dockComponent: PeerTubeDockComponent | ||
14 | |||
13 | constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { | 15 | constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { |
14 | super(player, options) | 16 | super(player, options) |
15 | 17 | ||
16 | this.player.addClass('peertube-dock') | 18 | player.ready(() => { |
17 | 19 | player.addClass('peertube-dock') | |
18 | this.player.ready(() => { | ||
19 | this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent | ||
20 | }) | 20 | }) |
21 | |||
22 | this.dockComponent = new PeerTubeDockComponent(player, options) | ||
23 | player.addChild(this.dockComponent) | ||
24 | } | ||
25 | |||
26 | dispose () { | ||
27 | this.dockComponent?.dispose() | ||
28 | this.player.removeChild(this.dockComponent) | ||
29 | this.player.removeClass('peertube-dock') | ||
30 | |||
31 | super.dispose() | ||
21 | } | 32 | } |
22 | } | 33 | } |
23 | 34 | ||
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts index 2742b21a1..e77b7dc6d 100644 --- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts +++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts | |||
@@ -31,6 +31,8 @@ class PeerTubeHotkeysPlugin extends Plugin { | |||
31 | 31 | ||
32 | dispose () { | 32 | dispose () { |
33 | document.removeEventListener('keydown', this.handleKeyFunction) | 33 | document.removeEventListener('keydown', this.handleKeyFunction) |
34 | |||
35 | super.dispose() | ||
34 | } | 36 | } |
35 | 37 | ||
36 | private onKeyDown (event: KeyboardEvent) { | 38 | private onKeyDown (event: KeyboardEvent) { |
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts deleted file mode 100644 index 26f923e92..000000000 --- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts +++ /dev/null | |||
@@ -1,155 +0,0 @@ | |||
1 | import { | ||
2 | CommonOptions, | ||
3 | NextPreviousVideoButtonOptions, | ||
4 | PeerTubeLinkButtonOptions, | ||
5 | PeertubePlayerManagerOptions, | ||
6 | PlayerMode | ||
7 | } from '../../types' | ||
8 | |||
9 | export class ControlBarOptionsBuilder { | ||
10 | private options: CommonOptions | ||
11 | |||
12 | constructor ( | ||
13 | globalOptions: PeertubePlayerManagerOptions, | ||
14 | private mode: PlayerMode | ||
15 | ) { | ||
16 | this.options = globalOptions.common | ||
17 | } | ||
18 | |||
19 | getChildrenOptions () { | ||
20 | const children = {} | ||
21 | |||
22 | if (this.options.previousVideo) { | ||
23 | Object.assign(children, this.getPreviousVideo()) | ||
24 | } | ||
25 | |||
26 | Object.assign(children, { playToggle: {} }) | ||
27 | |||
28 | if (this.options.nextVideo) { | ||
29 | Object.assign(children, this.getNextVideo()) | ||
30 | } | ||
31 | |||
32 | Object.assign(children, { | ||
33 | ...this.getTimeControls(), | ||
34 | |||
35 | flexibleWidthSpacer: {}, | ||
36 | |||
37 | ...this.getProgressControl(), | ||
38 | |||
39 | p2PInfoButton: { | ||
40 | p2pEnabled: this.options.p2pEnabled | ||
41 | }, | ||
42 | |||
43 | muteToggle: {}, | ||
44 | volumeControl: {}, | ||
45 | |||
46 | ...this.getSettingsButton() | ||
47 | }) | ||
48 | |||
49 | if (this.options.peertubeLink === true) { | ||
50 | Object.assign(children, { | ||
51 | peerTubeLinkButton: { | ||
52 | shortUUID: this.options.videoShortUUID, | ||
53 | instanceName: this.options.instanceName | ||
54 | } as PeerTubeLinkButtonOptions | ||
55 | }) | ||
56 | } | ||
57 | |||
58 | if (this.options.theaterButton === true) { | ||
59 | Object.assign(children, { | ||
60 | theaterButton: {} | ||
61 | }) | ||
62 | } | ||
63 | |||
64 | Object.assign(children, { | ||
65 | fullscreenToggle: {} | ||
66 | }) | ||
67 | |||
68 | return children | ||
69 | } | ||
70 | |||
71 | private getSettingsButton () { | ||
72 | const settingEntries: string[] = [] | ||
73 | |||
74 | if (!this.options.isLive) { | ||
75 | settingEntries.push('playbackRateMenuButton') | ||
76 | } | ||
77 | |||
78 | if (this.options.captions === true) settingEntries.push('captionsButton') | ||
79 | |||
80 | settingEntries.push('resolutionMenuButton') | ||
81 | |||
82 | return { | ||
83 | settingsButton: { | ||
84 | setup: { | ||
85 | maxHeightOffset: 40 | ||
86 | }, | ||
87 | entries: settingEntries | ||
88 | } | ||
89 | } | ||
90 | } | ||
91 | |||
92 | private getTimeControls () { | ||
93 | if (this.options.isLive) { | ||
94 | return { | ||
95 | peerTubeLiveDisplay: {} | ||
96 | } | ||
97 | } | ||
98 | |||
99 | return { | ||
100 | currentTimeDisplay: {}, | ||
101 | timeDivider: {}, | ||
102 | durationDisplay: {} | ||
103 | } | ||
104 | } | ||
105 | |||
106 | private getProgressControl () { | ||
107 | if (this.options.isLive) return {} | ||
108 | |||
109 | const loadProgressBar = this.mode === 'webtorrent' | ||
110 | ? 'peerTubeLoadProgressBar' | ||
111 | : 'loadProgressBar' | ||
112 | |||
113 | return { | ||
114 | progressControl: { | ||
115 | children: { | ||
116 | seekBar: { | ||
117 | children: { | ||
118 | [loadProgressBar]: {}, | ||
119 | mouseTimeDisplay: {}, | ||
120 | playProgressBar: {} | ||
121 | } | ||
122 | } | ||
123 | } | ||
124 | } | ||
125 | } | ||
126 | } | ||
127 | |||
128 | private getPreviousVideo () { | ||
129 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
130 | type: 'previous', | ||
131 | handler: this.options.previousVideo, | ||
132 | isDisabled: () => { | ||
133 | if (!this.options.hasPreviousVideo) return false | ||
134 | |||
135 | return !this.options.hasPreviousVideo() | ||
136 | } | ||
137 | } | ||
138 | |||
139 | return { previousVideoButton: buttonOptions } | ||
140 | } | ||
141 | |||
142 | private getNextVideo () { | ||
143 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
144 | type: 'next', | ||
145 | handler: this.options.nextVideo, | ||
146 | isDisabled: () => { | ||
147 | if (!this.options.hasNextVideo) return false | ||
148 | |||
149 | return !this.options.hasNextVideo() | ||
150 | } | ||
151 | } | ||
152 | |||
153 | return { nextVideoButton: buttonOptions } | ||
154 | } | ||
155 | } | ||
diff --git a/client/src/assets/player/shared/manager-options/index.ts b/client/src/assets/player/shared/manager-options/index.ts deleted file mode 100644 index 4934d8302..000000000 --- a/client/src/assets/player/shared/manager-options/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './manager-options-builder' | ||
diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts deleted file mode 100644 index 5d3ee4c4a..000000000 --- a/client/src/assets/player/shared/manager-options/manager-options-builder.ts +++ /dev/null | |||
@@ -1,186 +0,0 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { copyToClipboard } from '@root-helpers/utils' | ||
3 | import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' | ||
4 | import { isIOS, isSafari } from '@root-helpers/web-browser' | ||
5 | import { buildVideoLink, decorateVideoLink, pick } from '@shared/core-utils' | ||
6 | import { isDefaultLocale } from '@shared/core-utils/i18n' | ||
7 | import { VideoJSPluginOptions } from '../../types' | ||
8 | import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options' | ||
9 | import { ControlBarOptionsBuilder } from './control-bar-options-builder' | ||
10 | import { HLSOptionsBuilder } from './hls-options-builder' | ||
11 | import { WebTorrentOptionsBuilder } from './webtorrent-options-builder' | ||
12 | |||
13 | export class ManagerOptionsBuilder { | ||
14 | |||
15 | constructor ( | ||
16 | private mode: PlayerMode, | ||
17 | private options: PeertubePlayerManagerOptions, | ||
18 | private p2pMediaLoaderModule?: any | ||
19 | ) { | ||
20 | |||
21 | } | ||
22 | |||
23 | async getVideojsOptions (alreadyPlayed: boolean): Promise<videojs.PlayerOptions> { | ||
24 | const commonOptions = this.options.common | ||
25 | |||
26 | let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) | ||
27 | const html5 = { | ||
28 | preloadTextTracks: false | ||
29 | } | ||
30 | |||
31 | const plugins: VideoJSPluginOptions = { | ||
32 | peertube: { | ||
33 | mode: this.mode, | ||
34 | autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent | ||
35 | |||
36 | ...pick(commonOptions, [ | ||
37 | 'videoViewUrl', | ||
38 | 'videoViewIntervalMs', | ||
39 | 'authorizationHeader', | ||
40 | 'startTime', | ||
41 | 'videoDuration', | ||
42 | 'subtitle', | ||
43 | 'videoCaptions', | ||
44 | 'stopTime', | ||
45 | 'isLive', | ||
46 | 'videoUUID' | ||
47 | ]) | ||
48 | }, | ||
49 | metrics: { | ||
50 | mode: this.mode, | ||
51 | |||
52 | ...pick(commonOptions, [ | ||
53 | 'metricsUrl', | ||
54 | 'videoUUID' | ||
55 | ]) | ||
56 | } | ||
57 | } | ||
58 | |||
59 | if (commonOptions.playlist) { | ||
60 | plugins.playlist = commonOptions.playlist | ||
61 | } | ||
62 | |||
63 | if (this.mode === 'p2p-media-loader') { | ||
64 | const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule) | ||
65 | const options = await hlsOptionsBuilder.getPluginOptions() | ||
66 | |||
67 | Object.assign(plugins, pick(options, [ 'hlsjs', 'p2pMediaLoader' ])) | ||
68 | Object.assign(html5, options.html5) | ||
69 | } else if (this.mode === 'webtorrent') { | ||
70 | const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed)) | ||
71 | |||
72 | Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions()) | ||
73 | |||
74 | // WebTorrent plugin handles autoplay, because we do some hackish stuff in there | ||
75 | autoplay = false | ||
76 | } | ||
77 | |||
78 | const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode) | ||
79 | |||
80 | const videojsOptions = { | ||
81 | html5, | ||
82 | |||
83 | // We don't use text track settings for now | ||
84 | textTrackSettings: false as any, // FIXME: typings | ||
85 | controls: commonOptions.controls !== undefined ? commonOptions.controls : true, | ||
86 | loop: commonOptions.loop !== undefined ? commonOptions.loop : false, | ||
87 | |||
88 | muted: commonOptions.muted !== undefined | ||
89 | ? commonOptions.muted | ||
90 | : undefined, // Undefined so the player knows it has to check the local storage | ||
91 | |||
92 | autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed), | ||
93 | |||
94 | poster: commonOptions.poster, | ||
95 | inactivityTimeout: commonOptions.inactivityTimeout, | ||
96 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], | ||
97 | |||
98 | plugins, | ||
99 | |||
100 | controlBar: { | ||
101 | children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings | ||
102 | } | ||
103 | } | ||
104 | |||
105 | if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { | ||
106 | Object.assign(videojsOptions, { language: commonOptions.language }) | ||
107 | } | ||
108 | |||
109 | return videojsOptions | ||
110 | } | ||
111 | |||
112 | private getAutoPlayValue (autoplay: videojs.Autoplay, alreadyPlayed: boolean) { | ||
113 | if (autoplay !== true) return autoplay | ||
114 | |||
115 | // On first play, disable autoplay to avoid issues | ||
116 | // But if the player already played videos, we can safely autoplay next ones | ||
117 | if (isIOS() || isSafari()) { | ||
118 | return alreadyPlayed ? 'play' : false | ||
119 | } | ||
120 | |||
121 | return this.options.common.forceAutoplay | ||
122 | ? 'any' | ||
123 | : 'play' | ||
124 | } | ||
125 | |||
126 | getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) { | ||
127 | const content = () => { | ||
128 | const isLoopEnabled = player.options_['loop'] | ||
129 | |||
130 | const items = [ | ||
131 | { | ||
132 | icon: 'repeat', | ||
133 | label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''), | ||
134 | listener: function () { | ||
135 | player.options_['loop'] = !isLoopEnabled | ||
136 | } | ||
137 | }, | ||
138 | { | ||
139 | label: player.localize('Copy the video URL'), | ||
140 | listener: function () { | ||
141 | copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID })) | ||
142 | } | ||
143 | }, | ||
144 | { | ||
145 | label: player.localize('Copy the video URL at the current time'), | ||
146 | listener: function (this: videojs.Player) { | ||
147 | const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID }) | ||
148 | |||
149 | copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) | ||
150 | } | ||
151 | }, | ||
152 | { | ||
153 | icon: 'code', | ||
154 | label: player.localize('Copy embed code'), | ||
155 | listener: () => { | ||
156 | copyToClipboard(buildVideoOrPlaylistEmbed({ embedUrl: commonOptions.embedUrl, embedTitle: commonOptions.embedTitle })) | ||
157 | } | ||
158 | } | ||
159 | ] | ||
160 | |||
161 | if (this.mode === 'webtorrent') { | ||
162 | items.push({ | ||
163 | label: player.localize('Copy magnet URI'), | ||
164 | listener: function (this: videojs.Player) { | ||
165 | copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) | ||
166 | } | ||
167 | }) | ||
168 | } | ||
169 | |||
170 | items.push({ | ||
171 | icon: 'info', | ||
172 | label: player.localize('Stats for nerds'), | ||
173 | listener: () => { | ||
174 | player.stats().show() | ||
175 | } | ||
176 | }) | ||
177 | |||
178 | return items.map(i => ({ | ||
179 | ...i, | ||
180 | label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label | ||
181 | })) | ||
182 | } | ||
183 | |||
184 | return { content } | ||
185 | } | ||
186 | } | ||
diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts deleted file mode 100644 index 80eec02cf..000000000 --- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts +++ /dev/null | |||
@@ -1,47 +0,0 @@ | |||
1 | import { addQueryParams } from '../../../../../../shared/core-utils' | ||
2 | import { PeertubePlayerManagerOptions, WebtorrentPluginOptions } from '../../types' | ||
3 | |||
4 | export class WebTorrentOptionsBuilder { | ||
5 | |||
6 | constructor ( | ||
7 | private options: PeertubePlayerManagerOptions, | ||
8 | private autoPlayValue: any | ||
9 | ) { | ||
10 | |||
11 | } | ||
12 | |||
13 | getPluginOptions () { | ||
14 | const commonOptions = this.options.common | ||
15 | const webtorrentOptions = this.options.webtorrent | ||
16 | const p2pMediaLoaderOptions = this.options.p2pMediaLoader | ||
17 | |||
18 | const autoplay = this.autoPlayValue === 'play' | ||
19 | |||
20 | const webtorrent: WebtorrentPluginOptions = { | ||
21 | autoplay, | ||
22 | |||
23 | playerRefusedP2P: commonOptions.p2pEnabled === false, | ||
24 | videoDuration: commonOptions.videoDuration, | ||
25 | playerElement: commonOptions.playerElement, | ||
26 | |||
27 | videoFileToken: commonOptions.videoFileToken, | ||
28 | |||
29 | requiresUserAuth: commonOptions.requiresUserAuth, | ||
30 | |||
31 | buildWebSeedUrls: file => { | ||
32 | if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return [] | ||
33 | |||
34 | return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] | ||
35 | }, | ||
36 | |||
37 | videoFiles: webtorrentOptions.videoFiles.length !== 0 | ||
38 | ? webtorrentOptions.videoFiles | ||
39 | // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode | ||
40 | : p2pMediaLoaderOptions?.videoFiles || [], | ||
41 | |||
42 | startTime: commonOptions.startTime | ||
43 | } | ||
44 | |||
45 | return { webtorrent } | ||
46 | } | ||
47 | } | ||
diff --git a/client/src/assets/player/shared/metrics/metrics-plugin.ts b/client/src/assets/player/shared/metrics/metrics-plugin.ts index 2aae3e90a..48363a724 100644 --- a/client/src/assets/player/shared/metrics/metrics-plugin.ts +++ b/client/src/assets/player/shared/metrics/metrics-plugin.ts | |||
@@ -1,14 +1,15 @@ | |||
1 | import debug from 'debug' | ||
1 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
2 | import { PlaybackMetricCreate } from '../../../../../../shared/models' | ||
3 | import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types' | ||
4 | import { logger } from '@root-helpers/logger' | 3 | import { logger } from '@root-helpers/logger' |
4 | import { PlaybackMetricCreate } from '../../../../../../shared/models' | ||
5 | import { MetricsPluginOptions, PlayerNetworkInfo } from '../../types' | ||
6 | |||
7 | const debugLogger = debug('peertube:player:metrics') | ||
5 | 8 | ||
6 | const Plugin = videojs.getPlugin('plugin') | 9 | const Plugin = videojs.getPlugin('plugin') |
7 | 10 | ||
8 | class MetricsPlugin extends Plugin { | 11 | class MetricsPlugin extends Plugin { |
9 | private readonly metricsUrl: string | 12 | options_: MetricsPluginOptions |
10 | private readonly videoUUID: string | ||
11 | private readonly mode: PlayerMode | ||
12 | 13 | ||
13 | private downloadedBytesP2P = 0 | 14 | private downloadedBytesP2P = 0 |
14 | private downloadedBytesHTTP = 0 | 15 | private downloadedBytesHTTP = 0 |
@@ -28,29 +29,54 @@ class MetricsPlugin extends Plugin { | |||
28 | constructor (player: videojs.Player, options: MetricsPluginOptions) { | 29 | constructor (player: videojs.Player, options: MetricsPluginOptions) { |
29 | super(player) | 30 | super(player) |
30 | 31 | ||
31 | this.metricsUrl = options.metricsUrl | 32 | this.options_ = options |
32 | this.videoUUID = options.videoUUID | ||
33 | this.mode = options.mode | ||
34 | 33 | ||
35 | this.player.one('play', () => { | 34 | this.trackBytes() |
36 | this.runMetricsInterval() | 35 | this.trackResolutionChange() |
36 | this.trackErrors() | ||
37 | 37 | ||
38 | this.trackBytes() | 38 | this.one('play', () => { |
39 | this.trackResolutionChange() | 39 | this.player.on('video-change', () => { |
40 | this.trackErrors() | 40 | this.runMetricsIntervalOnPlay() |
41 | }) | ||
41 | }) | 42 | }) |
43 | |||
44 | this.runMetricsIntervalOnPlay() | ||
42 | } | 45 | } |
43 | 46 | ||
44 | dispose () { | 47 | dispose () { |
45 | if (this.metricsInterval) clearInterval(this.metricsInterval) | 48 | if (this.metricsInterval) clearInterval(this.metricsInterval) |
49 | |||
50 | super.dispose() | ||
51 | } | ||
52 | |||
53 | private runMetricsIntervalOnPlay () { | ||
54 | this.downloadedBytesP2P = 0 | ||
55 | this.downloadedBytesHTTP = 0 | ||
56 | this.uploadedBytesP2P = 0 | ||
57 | |||
58 | this.resolutionChanges = 0 | ||
59 | this.errors = 0 | ||
60 | |||
61 | this.lastPlayerNetworkInfo = undefined | ||
62 | |||
63 | debugLogger('Will track metrics on next play') | ||
64 | |||
65 | this.player.one('play', () => { | ||
66 | debugLogger('Tracking metrics') | ||
67 | |||
68 | this.runMetricsInterval() | ||
69 | }) | ||
46 | } | 70 | } |
47 | 71 | ||
48 | private runMetricsInterval () { | 72 | private runMetricsInterval () { |
73 | if (this.metricsInterval) clearInterval(this.metricsInterval) | ||
74 | |||
49 | this.metricsInterval = setInterval(() => { | 75 | this.metricsInterval = setInterval(() => { |
50 | let resolution: number | 76 | let resolution: number |
51 | let fps: number | 77 | let fps: number |
52 | 78 | ||
53 | if (this.mode === 'p2p-media-loader') { | 79 | if (this.player.usingPlugin('p2pMediaLoader')) { |
54 | const level = this.player.p2pMediaLoader().getCurrentLevel() | 80 | const level = this.player.p2pMediaLoader().getCurrentLevel() |
55 | if (!level) return | 81 | if (!level) return |
56 | 82 | ||
@@ -60,21 +86,23 @@ class MetricsPlugin extends Plugin { | |||
60 | fps = framerate | 86 | fps = framerate |
61 | ? parseInt(framerate, 10) | 87 | ? parseInt(framerate, 10) |
62 | : undefined | 88 | : undefined |
63 | } else { // webtorrent | 89 | } else if (this.player.usingPlugin('webVideo')) { |
64 | const videoFile = this.player.webtorrent().getCurrentVideoFile() | 90 | const videoFile = this.player.webVideo().getCurrentVideoFile() |
65 | if (!videoFile) return | 91 | if (!videoFile) return |
66 | 92 | ||
67 | resolution = videoFile.resolution.id | 93 | resolution = videoFile.resolution.id |
68 | fps = videoFile.fps && videoFile.fps !== -1 | 94 | fps = videoFile.fps && videoFile.fps !== -1 |
69 | ? videoFile.fps | 95 | ? videoFile.fps |
70 | : undefined | 96 | : undefined |
97 | } else { | ||
98 | return | ||
71 | } | 99 | } |
72 | 100 | ||
73 | const body: PlaybackMetricCreate = { | 101 | const body: PlaybackMetricCreate = { |
74 | resolution, | 102 | resolution, |
75 | fps, | 103 | fps, |
76 | 104 | ||
77 | playerMode: this.mode, | 105 | playerMode: this.options_.mode(), |
78 | 106 | ||
79 | resolutionChanges: this.resolutionChanges, | 107 | resolutionChanges: this.resolutionChanges, |
80 | 108 | ||
@@ -85,7 +113,7 @@ class MetricsPlugin extends Plugin { | |||
85 | 113 | ||
86 | uploadedBytesP2P: this.uploadedBytesP2P, | 114 | uploadedBytesP2P: this.uploadedBytesP2P, |
87 | 115 | ||
88 | videoId: this.videoUUID | 116 | videoId: this.options_.videoUUID() |
89 | } | 117 | } |
90 | 118 | ||
91 | this.resolutionChanges = 0 | 119 | this.resolutionChanges = 0 |
@@ -99,15 +127,13 @@ class MetricsPlugin extends Plugin { | |||
99 | 127 | ||
100 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) | 128 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) |
101 | 129 | ||
102 | return fetch(this.metricsUrl, { method: 'POST', body: JSON.stringify(body), headers }) | 130 | return fetch(this.options_.metricsUrl(), { method: 'POST', body: JSON.stringify(body), headers }) |
103 | .catch(err => logger.error('Cannot send metrics to the server.', err)) | 131 | .catch(err => logger.error('Cannot send metrics to the server.', err)) |
104 | }, this.CONSTANTS.METRICS_INTERVAL) | 132 | }, this.CONSTANTS.METRICS_INTERVAL) |
105 | } | 133 | } |
106 | 134 | ||
107 | private trackBytes () { | 135 | private trackBytes () { |
108 | this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => { | 136 | this.player.on('p2p-info', (_event, data: PlayerNetworkInfo) => { |
109 | if (!data) return | ||
110 | |||
111 | this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) | 137 | this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) |
112 | this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) | 138 | this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) |
113 | 139 | ||
@@ -115,10 +141,18 @@ class MetricsPlugin extends Plugin { | |||
115 | 141 | ||
116 | this.lastPlayerNetworkInfo = data | 142 | this.lastPlayerNetworkInfo = data |
117 | }) | 143 | }) |
144 | |||
145 | this.player.on('http-info', (_event, data: PlayerNetworkInfo) => { | ||
146 | this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) | ||
147 | }) | ||
118 | } | 148 | } |
119 | 149 | ||
120 | private trackResolutionChange () { | 150 | private trackResolutionChange () { |
121 | this.player.on('engineResolutionChange', () => { | 151 | this.player.on('engine-resolution-change', () => { |
152 | this.resolutionChanges++ | ||
153 | }) | ||
154 | |||
155 | this.player.on('user-resolution-change', () => { | ||
122 | this.resolutionChanges++ | 156 | this.resolutionChanges++ |
123 | }) | 157 | }) |
124 | } | 158 | } |
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts index 09cb98f2e..1bc3ca38d 100644 --- a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts +++ b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts | |||
@@ -2,22 +2,20 @@ import videojs from 'video.js' | |||
2 | 2 | ||
3 | const Component = videojs.getComponent('Component') | 3 | const Component = videojs.getComponent('Component') |
4 | class PeerTubeMobileButtons extends Component { | 4 | class PeerTubeMobileButtons extends Component { |
5 | private mainButton: HTMLDivElement | ||
5 | 6 | ||
6 | private rewind: Element | 7 | private rewind: Element |
7 | private forward: Element | 8 | private forward: Element |
8 | private rewindText: Element | 9 | private rewindText: Element |
9 | private forwardText: Element | 10 | private forwardText: Element |
10 | 11 | ||
11 | createEl () { | 12 | private touchStartHandler: (e: TouchEvent) => void |
12 | const container = super.createEl('div', { | ||
13 | className: 'vjs-mobile-buttons-overlay' | ||
14 | }) as HTMLDivElement | ||
15 | 13 | ||
16 | const mainButton = super.createEl('div', { | 14 | createEl () { |
17 | className: 'main-button' | 15 | const container = super.createEl('div', { className: 'vjs-mobile-buttons-overlay' }) as HTMLDivElement |
18 | }) as HTMLDivElement | 16 | this.mainButton = super.createEl('div', { className: 'main-button' }) as HTMLDivElement |
19 | 17 | ||
20 | mainButton.addEventListener('touchstart', e => { | 18 | this.touchStartHandler = e => { |
21 | e.stopPropagation() | 19 | e.stopPropagation() |
22 | 20 | ||
23 | if (this.player_.paused() || this.player_.ended()) { | 21 | if (this.player_.paused() || this.player_.ended()) { |
@@ -26,7 +24,9 @@ class PeerTubeMobileButtons extends Component { | |||
26 | } | 24 | } |
27 | 25 | ||
28 | this.player_.pause() | 26 | this.player_.pause() |
29 | }) | 27 | } |
28 | |||
29 | this.mainButton.addEventListener('touchstart', this.touchStartHandler, { passive: true }) | ||
30 | 30 | ||
31 | this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) | 31 | this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) |
32 | this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) | 32 | this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) |
@@ -40,12 +40,18 @@ class PeerTubeMobileButtons extends Component { | |||
40 | this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) | 40 | this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) |
41 | 41 | ||
42 | container.appendChild(this.rewind) | 42 | container.appendChild(this.rewind) |
43 | container.appendChild(mainButton) | 43 | container.appendChild(this.mainButton) |
44 | container.appendChild(this.forward) | 44 | container.appendChild(this.forward) |
45 | 45 | ||
46 | return container | 46 | return container |
47 | } | 47 | } |
48 | 48 | ||
49 | dispose () { | ||
50 | if (this.touchStartHandler) this.mainButton.removeEventListener('touchstart', this.touchStartHandler) | ||
51 | |||
52 | super.dispose() | ||
53 | } | ||
54 | |||
49 | displayFastSeek (amount: number) { | 55 | displayFastSeek (amount: number) { |
50 | if (amount === 0) { | 56 | if (amount === 0) { |
51 | this.hideRewind() | 57 | this.hideRewind() |
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts index 646e9f8c6..f31fa7ddb 100644 --- a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts +++ b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts | |||
@@ -21,6 +21,15 @@ class PeerTubeMobilePlugin extends Plugin { | |||
21 | 21 | ||
22 | private setCurrentTimeTimeout: ReturnType<typeof setTimeout> | 22 | private setCurrentTimeTimeout: ReturnType<typeof setTimeout> |
23 | 23 | ||
24 | private onPlayHandler: () => void | ||
25 | private onFullScreenChangeHandler: () => void | ||
26 | private onTouchStartHandler: (event: TouchEvent) => void | ||
27 | private onMobileButtonTouchStartHandler: (event: TouchEvent) => void | ||
28 | private sliderActiveHandler: () => void | ||
29 | private sliderInactiveHandler: () => void | ||
30 | |||
31 | private seekBar: videojs.Component | ||
32 | |||
24 | constructor (player: videojs.Player, options: videojs.PlayerOptions) { | 33 | constructor (player: videojs.Player, options: videojs.PlayerOptions) { |
25 | super(player, options) | 34 | super(player, options) |
26 | 35 | ||
@@ -36,18 +45,38 @@ class PeerTubeMobilePlugin extends Plugin { | |||
36 | (this.player.options_.userActions as any).click = false | 45 | (this.player.options_.userActions as any).click = false |
37 | this.player.options_.userActions.doubleClick = false | 46 | this.player.options_.userActions.doubleClick = false |
38 | 47 | ||
39 | this.player.one('play', () => { | 48 | this.onPlayHandler = () => this.initTouchStartEvents() |
40 | this.initTouchStartEvents() | 49 | this.player.one('play', this.onPlayHandler) |
41 | }) | 50 | |
51 | this.seekBar = this.player.getDescendant([ 'controlBar', 'progressControl', 'seekBar' ]) | ||
52 | |||
53 | this.sliderActiveHandler = () => this.player.addClass('vjs-mobile-sliding') | ||
54 | this.sliderInactiveHandler = () => this.player.removeClass('vjs-mobile-sliding') | ||
55 | |||
56 | this.seekBar.on('slideractive', this.sliderActiveHandler) | ||
57 | this.seekBar.on('sliderinactive', this.sliderInactiveHandler) | ||
58 | } | ||
59 | |||
60 | dispose () { | ||
61 | if (this.onPlayHandler) this.player.off('play', this.onPlayHandler) | ||
62 | if (this.onFullScreenChangeHandler) this.player.off('fullscreenchange', this.onFullScreenChangeHandler) | ||
63 | if (this.onTouchStartHandler) this.player.off('touchstart', this.onFullScreenChangeHandler) | ||
64 | if (this.onMobileButtonTouchStartHandler) { | ||
65 | this.peerTubeMobileButtons?.el().removeEventListener('touchstart', this.onMobileButtonTouchStartHandler) | ||
66 | } | ||
67 | |||
68 | super.dispose() | ||
42 | } | 69 | } |
43 | 70 | ||
44 | private handleFullscreenRotation () { | 71 | private handleFullscreenRotation () { |
45 | this.player.on('fullscreenchange', () => { | 72 | this.onFullScreenChangeHandler = () => { |
46 | if (!this.player.isFullscreen() || this.isPortraitVideo()) return | 73 | if (!this.player.isFullscreen() || this.isPortraitVideo()) return |
47 | 74 | ||
48 | screen.orientation.lock('landscape') | 75 | screen.orientation.lock('landscape') |
49 | .catch(err => logger.error('Cannot lock screen to landscape.', err)) | 76 | .catch(err => logger.error('Cannot lock screen to landscape.', err)) |
50 | }) | 77 | } |
78 | |||
79 | this.player.on('fullscreenchange', this.onFullScreenChangeHandler) | ||
51 | } | 80 | } |
52 | 81 | ||
53 | private isPortraitVideo () { | 82 | private isPortraitVideo () { |
@@ -80,19 +109,22 @@ class PeerTubeMobilePlugin extends Plugin { | |||
80 | this.lastTapEvent = event | 109 | this.lastTapEvent = event |
81 | } | 110 | } |
82 | 111 | ||
83 | this.player.on('touchstart', (event: TouchEvent) => { | 112 | this.onTouchStartHandler = event => { |
84 | // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it | 113 | // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it |
85 | if (this.player.userActive()) return | 114 | if (this.player.userActive()) return |
86 | 115 | ||
87 | handleTouchStart(event) | 116 | handleTouchStart(event) |
88 | }) | 117 | } |
118 | this.player.on('touchstart', this.onTouchStartHandler) | ||
89 | 119 | ||
90 | this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => { | 120 | this.onMobileButtonTouchStartHandler = event => { |
91 | // Prevent mousemove/click events firing on the player, that conflict with our user active logic | 121 | // Prevent mousemove/click events firing on the player, that conflict with our user active logic |
92 | event.preventDefault() | 122 | event.preventDefault() |
93 | 123 | ||
94 | handleTouchStart(event) | 124 | handleTouchStart(event) |
95 | }, { passive: false }) | 125 | } |
126 | |||
127 | this.peerTubeMobileButtons.el().addEventListener('touchstart', this.onMobileButtonTouchStartHandler, { passive: false }) | ||
96 | } | 128 | } |
97 | 129 | ||
98 | private onDoubleTap (event: TouchEvent) { | 130 | private onDoubleTap (event: TouchEvent) { |
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts index d05d6193c..d83ec625a 100644 --- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts | |||
@@ -14,6 +14,10 @@ type Metadata = { | |||
14 | levels: Level[] | 14 | levels: Level[] |
15 | } | 15 | } |
16 | 16 | ||
17 | // --------------------------------------------------------------------------- | ||
18 | // Source handler registration | ||
19 | // --------------------------------------------------------------------------- | ||
20 | |||
17 | type HookFn = (player: videojs.Player, hljs: Hlsjs) => void | 21 | type HookFn = (player: videojs.Player, hljs: Hlsjs) => void |
18 | 22 | ||
19 | const registerSourceHandler = function (vjs: typeof videojs) { | 23 | const registerSourceHandler = function (vjs: typeof videojs) { |
@@ -25,10 +29,13 @@ const registerSourceHandler = function (vjs: typeof videojs) { | |||
25 | const html5 = vjs.getTech('Html5') | 29 | const html5 = vjs.getTech('Html5') |
26 | 30 | ||
27 | if (!html5) { | 31 | if (!html5) { |
28 | logger.error('No Hml5 tech found in videojs') | 32 | logger.error('No "Html5" tech found in videojs') |
29 | return | 33 | return |
30 | } | 34 | } |
31 | 35 | ||
36 | // Already registered | ||
37 | if ((html5 as any).canPlaySource({ type: 'application/x-mpegURL' })) return | ||
38 | |||
32 | // FIXME: typings | 39 | // FIXME: typings |
33 | (html5 as any).registerSourceHandler({ | 40 | (html5 as any).registerSourceHandler({ |
34 | canHandleSource: function (source: videojs.Tech.SourceObject) { | 41 | canHandleSource: function (source: videojs.Tech.SourceObject) { |
@@ -56,32 +63,55 @@ const registerSourceHandler = function (vjs: typeof videojs) { | |||
56 | (vjs as any).Html5Hlsjs = Html5Hlsjs | 63 | (vjs as any).Html5Hlsjs = Html5Hlsjs |
57 | } | 64 | } |
58 | 65 | ||
59 | function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { | 66 | // --------------------------------------------------------------------------- |
60 | const player = this | 67 | // HLS options plugin |
68 | // --------------------------------------------------------------------------- | ||
61 | 69 | ||
62 | if (!options) return | 70 | const Plugin = videojs.getPlugin('plugin') |
63 | 71 | ||
64 | if (!player.srOptions_) { | 72 | class HLSJSConfigHandler extends Plugin { |
65 | player.srOptions_ = {} | 73 | |
66 | } | 74 | constructor (player: videojs.Player, options: HlsjsConfigHandlerOptions) { |
75 | super(player, options) | ||
76 | |||
77 | if (!options) return | ||
78 | |||
79 | if (!player.srOptions_) { | ||
80 | player.srOptions_ = {} | ||
81 | } | ||
82 | |||
83 | if (!player.srOptions_.hlsjsConfig) { | ||
84 | player.srOptions_.hlsjsConfig = options.hlsjsConfig | ||
85 | } | ||
67 | 86 | ||
68 | if (!player.srOptions_.hlsjsConfig) { | 87 | if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { |
69 | player.srOptions_.hlsjsConfig = options.hlsjsConfig | 88 | player.srOptions_.levelLabelHandler = options.levelLabelHandler |
89 | } | ||
90 | |||
91 | registerSourceHandler(videojs) | ||
70 | } | 92 | } |
71 | 93 | ||
72 | if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { | 94 | dispose () { |
73 | player.srOptions_.levelLabelHandler = options.levelLabelHandler | 95 | this.player.srOptions_ = undefined |
96 | |||
97 | const tech = this.player.tech(true) as any | ||
98 | if (tech.hlsProvider) { | ||
99 | tech.hlsProvider.dispose() | ||
100 | tech.hlsProvider = undefined | ||
101 | } | ||
102 | |||
103 | super.dispose() | ||
74 | } | 104 | } |
75 | } | 105 | } |
76 | 106 | ||
77 | const registerConfigPlugin = function (vjs: typeof videojs) { | 107 | videojs.registerPlugin('hlsjs', HLSJSConfigHandler) |
78 | // Used in Brightcove since we don't pass options directly there | 108 | |
79 | const registerVjsPlugin = vjs.registerPlugin || vjs.plugin | 109 | // --------------------------------------------------------------------------- |
80 | registerVjsPlugin('hlsjs', hlsjsConfigHandler) | 110 | // HLS JS source handler |
81 | } | 111 | // --------------------------------------------------------------------------- |
82 | 112 | ||
83 | class Html5Hlsjs { | 113 | export class Html5Hlsjs { |
84 | private static readonly hooks: { [id: string]: HookFn[] } = {} | 114 | private static hooks: { [id: string]: HookFn[] } = {} |
85 | 115 | ||
86 | private readonly videoElement: HTMLVideoElement | 116 | private readonly videoElement: HTMLVideoElement |
87 | private readonly errorCounts: ErrorCounts = {} | 117 | private readonly errorCounts: ErrorCounts = {} |
@@ -101,8 +131,9 @@ class Html5Hlsjs { | |||
101 | private dvrDuration: number = null | 131 | private dvrDuration: number = null |
102 | private edgeMargin: number = null | 132 | private edgeMargin: number = null |
103 | 133 | ||
104 | private handlers: { [ id in 'play' ]: EventListener } = { | 134 | private handlers: { [ id in 'play' | 'error' ]: EventListener } = { |
105 | play: null | 135 | play: null, |
136 | error: null | ||
106 | } | 137 | } |
107 | 138 | ||
108 | constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { | 139 | constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { |
@@ -115,7 +146,7 @@ class Html5Hlsjs { | |||
115 | this.videoElement = tech.el() as HTMLVideoElement | 146 | this.videoElement = tech.el() as HTMLVideoElement |
116 | this.player = vjs((tech.options_ as any).playerId) | 147 | this.player = vjs((tech.options_ as any).playerId) |
117 | 148 | ||
118 | this.videoElement.addEventListener('error', event => { | 149 | this.handlers.error = event => { |
119 | let errorTxt: string | 150 | let errorTxt: string |
120 | const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error | 151 | const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error |
121 | 152 | ||
@@ -143,7 +174,8 @@ class Html5Hlsjs { | |||
143 | } | 174 | } |
144 | 175 | ||
145 | logger.error(`MEDIA_ERROR: ${errorTxt}`) | 176 | logger.error(`MEDIA_ERROR: ${errorTxt}`) |
146 | }) | 177 | } |
178 | this.videoElement.addEventListener('error', this.handlers.error) | ||
147 | 179 | ||
148 | this.initialize() | 180 | this.initialize() |
149 | } | 181 | } |
@@ -174,6 +206,7 @@ class Html5Hlsjs { | |||
174 | // See comment for `initialize` method. | 206 | // See comment for `initialize` method. |
175 | dispose () { | 207 | dispose () { |
176 | this.videoElement.removeEventListener('play', this.handlers.play) | 208 | this.videoElement.removeEventListener('play', this.handlers.play) |
209 | this.videoElement.removeEventListener('error', this.handlers.error) | ||
177 | 210 | ||
178 | // FIXME: https://github.com/video-dev/hls.js/issues/4092 | 211 | // FIXME: https://github.com/video-dev/hls.js/issues/4092 |
179 | const untypedHLS = this.hls as any | 212 | const untypedHLS = this.hls as any |
@@ -200,6 +233,10 @@ class Html5Hlsjs { | |||
200 | return true | 233 | return true |
201 | } | 234 | } |
202 | 235 | ||
236 | static removeAllHooks () { | ||
237 | Html5Hlsjs.hooks = {} | ||
238 | } | ||
239 | |||
203 | private _executeHooksFor (type: string) { | 240 | private _executeHooksFor (type: string) { |
204 | if (Html5Hlsjs.hooks[type] === undefined) { | 241 | if (Html5Hlsjs.hooks[type] === undefined) { |
205 | return | 242 | return |
@@ -421,7 +458,7 @@ class Html5Hlsjs { | |||
421 | ? data.level | 458 | ? data.level |
422 | : -1 | 459 | : -1 |
423 | 460 | ||
424 | this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) | 461 | this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false }) |
425 | }) | 462 | }) |
426 | 463 | ||
427 | this.hls.attachMedia(this.videoElement) | 464 | this.hls.attachMedia(this.videoElement) |
@@ -433,9 +470,3 @@ class Html5Hlsjs { | |||
433 | this._initHlsjs() | 470 | this._initHlsjs() |
434 | } | 471 | } |
435 | } | 472 | } |
436 | |||
437 | export { | ||
438 | Html5Hlsjs, | ||
439 | registerSourceHandler, | ||
440 | registerConfigPlugin | ||
441 | } | ||
diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts index e6f525fea..fe967a730 100644 --- a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts | |||
@@ -3,19 +3,12 @@ import videojs from 'video.js' | |||
3 | import { Events, Segment } from '@peertube/p2p-media-loader-core' | 3 | import { Events, Segment } from '@peertube/p2p-media-loader-core' |
4 | import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' | 4 | import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' |
5 | import { logger } from '@root-helpers/logger' | 5 | import { logger } from '@root-helpers/logger' |
6 | import { addQueryParams, timeToInt } from '@shared/core-utils' | 6 | import { addQueryParams } from '@shared/core-utils' |
7 | import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' | 7 | import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' |
8 | import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' | 8 | import { SettingsButton } from '../settings/settings-menu-button' |
9 | |||
10 | registerConfigPlugin(videojs) | ||
11 | registerSourceHandler(videojs) | ||
12 | 9 | ||
13 | const Plugin = videojs.getPlugin('plugin') | 10 | const Plugin = videojs.getPlugin('plugin') |
14 | class P2pMediaLoaderPlugin extends Plugin { | 11 | class P2pMediaLoaderPlugin extends Plugin { |
15 | |||
16 | private readonly CONSTANTS = { | ||
17 | INFO_SCHEDULER: 1000 // Don't change this | ||
18 | } | ||
19 | private readonly options: P2PMediaLoaderPluginOptions | 12 | private readonly options: P2PMediaLoaderPluginOptions |
20 | 13 | ||
21 | private hlsjs: Hlsjs | 14 | private hlsjs: Hlsjs |
@@ -31,7 +24,6 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
31 | pendingDownload: [] as number[], | 24 | pendingDownload: [] as number[], |
32 | totalDownload: 0 | 25 | totalDownload: 0 |
33 | } | 26 | } |
34 | private startTime: number | ||
35 | 27 | ||
36 | private networkInfoInterval: any | 28 | private networkInfoInterval: any |
37 | 29 | ||
@@ -39,7 +31,6 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
39 | super(player) | 31 | super(player) |
40 | 32 | ||
41 | this.options = options | 33 | this.options = options |
42 | this.startTime = timeToInt(options.startTime) | ||
43 | 34 | ||
44 | // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 | 35 | // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 |
45 | if (!(videojs as any).Html5Hlsjs) { | 36 | if (!(videojs as any).Html5Hlsjs) { |
@@ -77,17 +68,22 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
77 | }) | 68 | }) |
78 | 69 | ||
79 | player.ready(() => { | 70 | player.ready(() => { |
80 | this.initializeCore() | ||
81 | |||
82 | this.initializePlugin() | 71 | this.initializePlugin() |
83 | }) | 72 | }) |
84 | } | 73 | } |
85 | 74 | ||
86 | dispose () { | 75 | dispose () { |
87 | if (this.hlsjs) this.hlsjs.destroy() | 76 | this.p2pEngine?.removeAllListeners() |
88 | if (this.p2pEngine) this.p2pEngine.destroy() | 77 | this.p2pEngine?.destroy() |
78 | |||
79 | this.hlsjs?.destroy() | ||
80 | this.options.segmentValidator?.destroy(); | ||
81 | |||
82 | (videojs as any).Html5Hlsjs?.removeAllHooks() | ||
89 | 83 | ||
90 | clearInterval(this.networkInfoInterval) | 84 | clearInterval(this.networkInfoInterval) |
85 | |||
86 | super.dispose() | ||
91 | } | 87 | } |
92 | 88 | ||
93 | getCurrentLevel () { | 89 | getCurrentLevel () { |
@@ -104,18 +100,6 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
104 | return this.hlsjs | 100 | return this.hlsjs |
105 | } | 101 | } |
106 | 102 | ||
107 | private initializeCore () { | ||
108 | this.player.one('play', () => { | ||
109 | this.player.addClass('vjs-has-big-play-button-clicked') | ||
110 | }) | ||
111 | |||
112 | this.player.one('canplay', () => { | ||
113 | if (this.startTime) { | ||
114 | this.player.currentTime(this.startTime) | ||
115 | } | ||
116 | }) | ||
117 | } | ||
118 | |||
119 | private initializePlugin () { | 103 | private initializePlugin () { |
120 | initHlsJsPlayer(this.hlsjs) | 104 | initHlsJsPlayer(this.hlsjs) |
121 | 105 | ||
@@ -133,7 +117,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
133 | 117 | ||
134 | this.runStats() | 118 | this.runStats() |
135 | 119 | ||
136 | this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engineResolutionChange')) | 120 | this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engine-resolution-change')) |
137 | } | 121 | } |
138 | 122 | ||
139 | private runStats () { | 123 | private runStats () { |
@@ -167,7 +151,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
167 | this.statsP2PBytes.pendingUpload = [] | 151 | this.statsP2PBytes.pendingUpload = [] |
168 | this.statsHTTPBytes.pendingDownload = [] | 152 | this.statsHTTPBytes.pendingDownload = [] |
169 | 153 | ||
170 | return this.player.trigger('p2pInfo', { | 154 | return this.player.trigger('p2p-info', { |
171 | source: 'p2p-media-loader', | 155 | source: 'p2p-media-loader', |
172 | http: { | 156 | http: { |
173 | downloadSpeed: httpDownloadSpeed, | 157 | downloadSpeed: httpDownloadSpeed, |
@@ -182,7 +166,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
182 | }, | 166 | }, |
183 | bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 | 167 | bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 |
184 | } as PlayerNetworkInfo) | 168 | } as PlayerNetworkInfo) |
185 | }, this.CONSTANTS.INFO_SCHEDULER) | 169 | }, 1000) |
186 | } | 170 | } |
187 | 171 | ||
188 | private arraySum (data: number[]) { | 172 | private arraySum (data: number[]) { |
@@ -190,10 +174,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
190 | } | 174 | } |
191 | 175 | ||
192 | private fallbackToBuiltInIOS () { | 176 | private fallbackToBuiltInIOS () { |
193 | logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.'); | 177 | logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.') |
194 | |||
195 | // Workaround to force video.js to not re create a video element | ||
196 | (this.player as any).playerElIngest_ = this.player.el().parentNode | ||
197 | 178 | ||
198 | this.player.src({ | 179 | this.player.src({ |
199 | type: this.options.type, | 180 | type: this.options.type, |
@@ -203,9 +184,14 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
203 | }) | 184 | }) |
204 | }) | 185 | }) |
205 | 186 | ||
206 | this.player.ready(() => { | 187 | // Resolution button is not supported in built-in HLS player |
207 | this.initializeCore() | 188 | this.getResolutionButton().hide() |
208 | }) | 189 | } |
190 | |||
191 | private getResolutionButton () { | ||
192 | const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton | ||
193 | |||
194 | return settingsButton.menu.getChild('resolutionMenuButton') | ||
209 | } | 195 | } |
210 | } | 196 | } |
211 | 197 | ||
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts index e86d3d159..a2f7e676d 100644 --- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts | |||
@@ -9,30 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string | |||
9 | 9 | ||
10 | const maxRetries = 10 | 10 | const maxRetries = 10 |
11 | 11 | ||
12 | function segmentValidatorFactory (options: { | 12 | export class SegmentValidator { |
13 | serverUrl: string | 13 | |
14 | segmentsSha256Url: string | 14 | private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/ |
15 | authorizationHeader: () => string | 15 | |
16 | requiresUserAuth: boolean | 16 | private destroyed = false |
17 | requiresPassword: boolean | 17 | |
18 | videoPassword: () => string | 18 | constructor (private readonly options: { |
19 | }) { | 19 | serverUrl: string |
20 | const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options | 20 | segmentsSha256Url: string |
21 | 21 | authorizationHeader: () => string | |
22 | let segmentsJSON = fetchSha256Segments({ | 22 | requiresUserAuth: boolean |
23 | serverUrl, | 23 | requiresPassword: boolean |
24 | segmentsSha256Url, | 24 | videoPassword: () => string |
25 | authorizationHeader, | 25 | }) { |
26 | requiresUserAuth, | 26 | |
27 | requiresPassword, | 27 | } |
28 | videoPassword | 28 | |
29 | }) | 29 | async validate (segment: Segment, _method: string, _peerId: string, retry = 1) { |
30 | const regex = /bytes=(\d+)-(\d+)/ | 30 | if (this.destroyed) return |
31 | 31 | ||
32 | return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { | ||
33 | const filename = basename(removeQueryParams(segment.url)) | 32 | const filename = basename(removeQueryParams(segment.url)) |
34 | 33 | ||
35 | const segmentValue = (await segmentsJSON)[filename] | 34 | const segmentValue = (await this.fetchSha256Segments())[filename] |
36 | 35 | ||
37 | if (!segmentValue && retry > maxRetries) { | 36 | if (!segmentValue && retry > maxRetries) { |
38 | throw new Error(`Unknown segment name ${filename} in segment validator`) | 37 | throw new Error(`Unknown segment name ${filename} in segment validator`) |
@@ -43,15 +42,7 @@ function segmentValidatorFactory (options: { | |||
43 | 42 | ||
44 | await wait(500) | 43 | await wait(500) |
45 | 44 | ||
46 | segmentsJSON = fetchSha256Segments({ | 45 | await this.validate(segment, _method, _peerId, retry + 1) |
47 | serverUrl, | ||
48 | segmentsSha256Url, | ||
49 | authorizationHeader, | ||
50 | requiresUserAuth, | ||
51 | requiresPassword, | ||
52 | videoPassword | ||
53 | }) | ||
54 | await segmentValidator(segment, _method, _peerId, retry + 1) | ||
55 | 46 | ||
56 | return | 47 | return |
57 | } | 48 | } |
@@ -62,7 +53,7 @@ function segmentValidatorFactory (options: { | |||
62 | if (typeof segmentValue === 'string') { | 53 | if (typeof segmentValue === 'string') { |
63 | hashShouldBe = segmentValue | 54 | hashShouldBe = segmentValue |
64 | } else { | 55 | } else { |
65 | const captured = regex.exec(segment.range) | 56 | const captured = this.bytesRangeRegex.exec(segment.range) |
66 | range = captured[1] + '-' + captured[2] | 57 | range = captured[1] + '-' + captured[2] |
67 | 58 | ||
68 | hashShouldBe = segmentValue[range] | 59 | hashShouldBe = segmentValue[range] |
@@ -72,7 +63,7 @@ function segmentValidatorFactory (options: { | |||
72 | throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) | 63 | throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) |
73 | } | 64 | } |
74 | 65 | ||
75 | const calculatedSha = await sha256Hex(segment.data) | 66 | const calculatedSha = await this.sha256Hex(segment.data) |
76 | if (calculatedSha !== hashShouldBe) { | 67 | if (calculatedSha !== hashShouldBe) { |
77 | throw new Error( | 68 | throw new Error( |
78 | `Hashes does not correspond for segment ${filename}/${range}` + | 69 | `Hashes does not correspond for segment ${filename}/${range}` + |
@@ -80,65 +71,53 @@ function segmentValidatorFactory (options: { | |||
80 | ) | 71 | ) |
81 | } | 72 | } |
82 | } | 73 | } |
83 | } | ||
84 | 74 | ||
85 | // --------------------------------------------------------------------------- | 75 | destroy () { |
76 | this.destroyed = true | ||
77 | } | ||
86 | 78 | ||
87 | export { | 79 | private fetchSha256Segments (): Promise<SegmentsJSON> { |
88 | segmentValidatorFactory | 80 | let headers: { [ id: string ]: string } = {} |
89 | } | ||
90 | 81 | ||
91 | // --------------------------------------------------------------------------- | 82 | if (isSameOrigin(this.options.serverUrl, this.options.segmentsSha256Url)) { |
92 | 83 | if (this.options.requiresPassword) headers = { 'x-peertube-video-password': this.options.videoPassword() } | |
93 | function fetchSha256Segments (options: { | 84 | else if (this.options.requiresUserAuth) headers = { Authorization: this.options.authorizationHeader() } |
94 | serverUrl: string | 85 | } |
95 | segmentsSha256Url: string | 86 | |
96 | authorizationHeader: () => string | 87 | return fetch(this.options.segmentsSha256Url, { headers }) |
97 | requiresUserAuth: boolean | 88 | .then(res => res.json() as Promise<SegmentsJSON>) |
98 | requiresPassword: boolean | 89 | .catch(err => { |
99 | videoPassword: () => string | 90 | logger.error('Cannot get sha256 segments', err) |
100 | }): Promise<SegmentsJSON> { | 91 | return {} |
101 | const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options | 92 | }) |
102 | |||
103 | let headers: { [ id: string ]: string } = {} | ||
104 | if (isSameOrigin(serverUrl, segmentsSha256Url)) { | ||
105 | if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() } | ||
106 | else if (requiresUserAuth) headers = { Authorization: authorizationHeader() } | ||
107 | } | 93 | } |
108 | 94 | ||
109 | return fetch(segmentsSha256Url, { headers }) | 95 | private async sha256Hex (data?: ArrayBuffer) { |
110 | .then(res => res.json() as Promise<SegmentsJSON>) | 96 | if (!data) return undefined |
111 | .catch(err => { | ||
112 | logger.error('Cannot get sha256 segments', err) | ||
113 | return {} | ||
114 | }) | ||
115 | } | ||
116 | 97 | ||
117 | async function sha256Hex (data?: ArrayBuffer) { | 98 | if (window.crypto.subtle) { |
118 | if (!data) return undefined | 99 | return window.crypto.subtle.digest('SHA-256', data) |
100 | .then(data => this.bufferToHex(data)) | ||
101 | } | ||
119 | 102 | ||
120 | if (window.crypto.subtle) { | 103 | // Fallback for non HTTPS context |
121 | return window.crypto.subtle.digest('SHA-256', data) | 104 | const shaModule = (await import('sha.js') as any).default |
122 | .then(data => bufferToHex(data)) | 105 | // eslint-disable-next-line new-cap |
106 | return new shaModule.sha256().update(Buffer.from(data)).digest('hex') | ||
123 | } | 107 | } |
124 | 108 | ||
125 | // Fallback for non HTTPS context | 109 | // Thanks: https://stackoverflow.com/a/53307879 |
126 | const shaModule = (await import('sha.js') as any).default | 110 | private bufferToHex (buffer?: ArrayBuffer) { |
127 | // eslint-disable-next-line new-cap | 111 | if (!buffer) return '' |
128 | return new shaModule.sha256().update(Buffer.from(data)).digest('hex') | ||
129 | } | ||
130 | |||
131 | // Thanks: https://stackoverflow.com/a/53307879 | ||
132 | function bufferToHex (buffer?: ArrayBuffer) { | ||
133 | if (!buffer) return '' | ||
134 | 112 | ||
135 | let s = '' | 113 | let s = '' |
136 | const h = '0123456789abcdef' | 114 | const h = '0123456789abcdef' |
137 | const o = new Uint8Array(buffer) | 115 | const o = new Uint8Array(buffer) |
138 | 116 | ||
139 | o.forEach((v: any) => { | 117 | o.forEach((v: any) => { |
140 | s += h[v >> 4] + h[v & 15] | 118 | s += h[v >> 4] + h[v & 15] |
141 | }) | 119 | }) |
142 | 120 | ||
143 | return s | 121 | return s |
122 | } | ||
144 | } | 123 | } |
diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts index af2147749..f52ec75f4 100644 --- a/client/src/assets/player/shared/peertube/peertube-plugin.ts +++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import debug from 'debug' | 1 | import debug from 'debug' |
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { logger } from '@root-helpers/logger' | 3 | import { logger } from '@root-helpers/logger' |
4 | import { isMobile } from '@root-helpers/web-browser' | 4 | import { isIOS, isMobile } from '@root-helpers/web-browser' |
5 | import { timeToInt } from '@shared/core-utils' | 5 | import { timeToInt } from '@shared/core-utils' |
6 | import { VideoView, VideoViewEvent } from '@shared/models/videos' | 6 | import { VideoView, VideoViewEvent } from '@shared/models/videos' |
7 | import { | 7 | import { |
@@ -13,7 +13,7 @@ import { | |||
13 | saveVideoWatchHistory, | 13 | saveVideoWatchHistory, |
14 | saveVolumeInStore | 14 | saveVolumeInStore |
15 | } from '../../peertube-player-local-storage' | 15 | } from '../../peertube-player-local-storage' |
16 | import { PeerTubePluginOptions, VideoJSCaption } from '../../types' | 16 | import { PeerTubePluginOptions } from '../../types' |
17 | import { SettingsButton } from '../settings/settings-menu-button' | 17 | import { SettingsButton } from '../settings/settings-menu-button' |
18 | 18 | ||
19 | const debugLogger = debug('peertube:player:peertube') | 19 | const debugLogger = debug('peertube:player:peertube') |
@@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube') | |||
21 | const Plugin = videojs.getPlugin('plugin') | 21 | const Plugin = videojs.getPlugin('plugin') |
22 | 22 | ||
23 | class PeerTubePlugin extends Plugin { | 23 | class PeerTubePlugin extends Plugin { |
24 | private readonly videoViewUrl: string | 24 | private readonly videoViewUrl: () => string |
25 | private readonly authorizationHeader: () => string | 25 | private readonly authorizationHeader: () => string |
26 | private readonly initialInactivityTimeout: number | ||
26 | 27 | ||
27 | private readonly videoUUID: string | 28 | private readonly hasAutoplay: () => videojs.Autoplay |
28 | private readonly startTime: number | ||
29 | |||
30 | private readonly videoViewIntervalMs: number | ||
31 | 29 | ||
32 | private videoCaptions: VideoJSCaption[] | 30 | private currentSubtitle: string |
33 | private defaultSubtitle: string | 31 | private currentPlaybackRate: number |
34 | 32 | ||
35 | private videoViewInterval: any | 33 | private videoViewInterval: any |
36 | 34 | ||
37 | private menuOpened = false | 35 | private menuOpened = false |
38 | private mouseInControlBar = false | 36 | private mouseInControlBar = false |
39 | private mouseInSettings = false | 37 | private mouseInSettings = false |
40 | private readonly initialInactivityTimeout: number | ||
41 | 38 | ||
42 | constructor (player: videojs.Player, options?: PeerTubePluginOptions) { | 39 | private videoViewOnPlayHandler: (...args: any[]) => void |
40 | private videoViewOnSeekedHandler: (...args: any[]) => void | ||
41 | private videoViewOnEndedHandler: (...args: any[]) => void | ||
42 | |||
43 | private stopTimeHandler: (...args: any[]) => void | ||
44 | |||
45 | constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) { | ||
43 | super(player) | 46 | super(player) |
44 | 47 | ||
45 | this.videoViewUrl = options.videoViewUrl | 48 | this.videoViewUrl = options.videoViewUrl |
46 | this.authorizationHeader = options.authorizationHeader | 49 | this.authorizationHeader = options.authorizationHeader |
47 | this.videoUUID = options.videoUUID | 50 | this.hasAutoplay = options.hasAutoplay |
48 | this.startTime = timeToInt(options.startTime) | ||
49 | this.videoViewIntervalMs = options.videoViewIntervalMs | ||
50 | 51 | ||
51 | this.videoCaptions = options.videoCaptions | ||
52 | this.initialInactivityTimeout = this.player.options_.inactivityTimeout | 52 | this.initialInactivityTimeout = this.player.options_.inactivityTimeout |
53 | 53 | ||
54 | if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay') | 54 | this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle() |
55 | |||
56 | this.initializePlayer() | ||
57 | this.initOnVideoChange() | ||
58 | |||
59 | this.deleteLegacyIndexedDB() | ||
55 | 60 | ||
56 | this.player.on('autoplay-failure', () => { | 61 | this.player.on('autoplay-failure', () => { |
62 | debugLogger('Autoplay failed') | ||
63 | |||
57 | this.player.removeClass('vjs-has-autoplay') | 64 | this.player.removeClass('vjs-has-autoplay') |
65 | |||
66 | // Fix a bug on iOS where the big play button is not displayed when autoplay fails | ||
67 | if (isIOS()) this.player.hasStarted(false) | ||
58 | }) | 68 | }) |
59 | 69 | ||
60 | this.player.ready(() => { | 70 | this.player.on('ratechange', () => { |
71 | this.currentPlaybackRate = this.player.playbackRate() | ||
72 | |||
73 | this.player.defaultPlaybackRate(this.currentPlaybackRate) | ||
74 | }) | ||
75 | |||
76 | this.player.one('canplay', () => { | ||
61 | const playerOptions = this.player.options_ | 77 | const playerOptions = this.player.options_ |
62 | 78 | ||
63 | const volume = getStoredVolume() | 79 | const volume = getStoredVolume() |
@@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin { | |||
65 | 81 | ||
66 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | 82 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() |
67 | if (muted !== undefined) this.player.muted(muted) | 83 | if (muted !== undefined) this.player.muted(muted) |
84 | }) | ||
68 | 85 | ||
69 | this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() | 86 | this.player.ready(() => { |
70 | 87 | ||
71 | this.player.on('volumechange', () => { | 88 | this.player.on('volumechange', () => { |
72 | saveVolumeInStore(this.player.volume()) | 89 | saveVolumeInStore(this.player.volume()) |
73 | saveMuteInStore(this.player.muted()) | 90 | saveMuteInStore(this.player.muted()) |
74 | }) | 91 | }) |
75 | 92 | ||
76 | if (options.stopTime) { | ||
77 | const stopTime = timeToInt(options.stopTime) | ||
78 | const self = this | ||
79 | |||
80 | this.player.on('timeupdate', function onTimeUpdate () { | ||
81 | if (self.player.currentTime() > stopTime) { | ||
82 | self.player.pause() | ||
83 | self.player.trigger('stopped') | ||
84 | |||
85 | self.player.off('timeupdate', onTimeUpdate) | ||
86 | } | ||
87 | }) | ||
88 | } | ||
89 | |||
90 | this.player.textTracks().addEventListener('change', () => { | 93 | this.player.textTracks().addEventListener('change', () => { |
91 | const showing = this.player.textTracks().tracks_.find(t => { | 94 | const showing = this.player.textTracks().tracks_.find(t => { |
92 | return t.kind === 'captions' && t.mode === 'showing' | 95 | return t.kind === 'captions' && t.mode === 'showing' |
@@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin { | |||
94 | 97 | ||
95 | if (!showing) { | 98 | if (!showing) { |
96 | saveLastSubtitle('off') | 99 | saveLastSubtitle('off') |
100 | this.currentSubtitle = undefined | ||
97 | return | 101 | return |
98 | } | 102 | } |
99 | 103 | ||
104 | this.currentSubtitle = showing.language | ||
100 | saveLastSubtitle(showing.language) | 105 | saveLastSubtitle(showing.language) |
101 | }) | 106 | }) |
102 | 107 | ||
103 | this.player.on('sourcechange', () => this.initCaptions()) | 108 | this.player.on('video-change', () => { |
104 | 109 | this.initOnVideoChange() | |
105 | this.player.duration(options.videoDuration) | 110 | }) |
106 | |||
107 | this.initializePlayer() | ||
108 | this.runUserViewing() | ||
109 | }) | 111 | }) |
110 | } | 112 | } |
111 | 113 | ||
112 | dispose () { | 114 | dispose () { |
113 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) | 115 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) |
116 | |||
117 | super.dispose() | ||
114 | } | 118 | } |
115 | 119 | ||
116 | onMenuOpened () { | 120 | onMenuOpened () { |
@@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin { | |||
162 | 166 | ||
163 | this.initSmoothProgressBar() | 167 | this.initSmoothProgressBar() |
164 | 168 | ||
165 | this.initCaptions() | 169 | this.player.ready(() => { |
166 | 170 | this.listenControlBarMouse() | |
167 | this.listenControlBarMouse() | 171 | }) |
168 | 172 | ||
169 | this.listenFullScreenChange() | 173 | this.listenFullScreenChange() |
170 | } | 174 | } |
171 | 175 | ||
176 | private initOnVideoChange () { | ||
177 | if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay') | ||
178 | else this.player.removeClass('vjs-has-autoplay') | ||
179 | |||
180 | if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) { | ||
181 | debugLogger('Setting playback rate to ' + this.currentPlaybackRate) | ||
182 | |||
183 | this.player.playbackRate(this.currentPlaybackRate) | ||
184 | } | ||
185 | |||
186 | this.player.ready(() => { | ||
187 | this.initCaptions() | ||
188 | this.updateControlBar() | ||
189 | }) | ||
190 | |||
191 | this.handleStartStopTime() | ||
192 | this.runUserViewing() | ||
193 | } | ||
194 | |||
172 | // --------------------------------------------------------------------------- | 195 | // --------------------------------------------------------------------------- |
173 | 196 | ||
174 | private runUserViewing () { | 197 | private runUserViewing () { |
175 | let lastCurrentTime = this.startTime | 198 | const startTime = timeToInt(this.options.startTime()) |
199 | |||
200 | let lastCurrentTime = startTime | ||
176 | let lastViewEvent: VideoViewEvent | 201 | let lastViewEvent: VideoViewEvent |
177 | 202 | ||
178 | this.player.one('play', () => { | 203 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) |
179 | this.notifyUserIsWatching(this.startTime, lastViewEvent) | 204 | if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler) |
180 | }) | 205 | if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler) |
206 | if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler) | ||
181 | 207 | ||
182 | this.player.on('seeked', () => { | 208 | this.videoViewOnPlayHandler = () => { |
209 | this.notifyUserIsWatching(startTime, lastViewEvent) | ||
210 | } | ||
211 | |||
212 | this.videoViewOnSeekedHandler = () => { | ||
183 | const diff = Math.floor(this.player.currentTime()) - lastCurrentTime | 213 | const diff = Math.floor(this.player.currentTime()) - lastCurrentTime |
184 | 214 | ||
185 | // Don't take into account small forwards | 215 | // Don't take into account small forwards |
186 | if (diff > 0 && diff < 3) return | 216 | if (diff > 0 && diff < 3) return |
187 | 217 | ||
188 | lastViewEvent = 'seek' | 218 | lastViewEvent = 'seek' |
189 | }) | 219 | } |
190 | 220 | ||
191 | this.player.one('ended', () => { | 221 | this.videoViewOnEndedHandler = () => { |
192 | const currentTime = Math.floor(this.player.duration()) | 222 | const currentTime = Math.floor(this.player.duration()) |
193 | lastCurrentTime = currentTime | 223 | lastCurrentTime = currentTime |
194 | 224 | ||
195 | this.notifyUserIsWatching(currentTime, lastViewEvent) | 225 | this.notifyUserIsWatching(currentTime, lastViewEvent) |
196 | 226 | ||
197 | lastViewEvent = undefined | 227 | lastViewEvent = undefined |
198 | }) | 228 | } |
229 | |||
230 | this.player.one('play', this.videoViewOnPlayHandler) | ||
231 | this.player.on('seeked', this.videoViewOnSeekedHandler) | ||
232 | this.player.one('ended', this.videoViewOnEndedHandler) | ||
199 | 233 | ||
200 | this.videoViewInterval = setInterval(() => { | 234 | this.videoViewInterval = setInterval(() => { |
201 | const currentTime = Math.floor(this.player.currentTime()) | 235 | const currentTime = Math.floor(this.player.currentTime()) |
@@ -209,13 +243,13 @@ class PeerTubePlugin extends Plugin { | |||
209 | .catch(err => logger.error('Cannot notify user is watching.', err)) | 243 | .catch(err => logger.error('Cannot notify user is watching.', err)) |
210 | 244 | ||
211 | lastViewEvent = undefined | 245 | lastViewEvent = undefined |
212 | }, this.videoViewIntervalMs) | 246 | }, this.options.videoViewIntervalMs) |
213 | } | 247 | } |
214 | 248 | ||
215 | private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { | 249 | private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { |
216 | // Server won't save history, so save the video position in local storage | 250 | // Server won't save history, so save the video position in local storage |
217 | if (!this.authorizationHeader()) { | 251 | if (!this.authorizationHeader()) { |
218 | saveVideoWatchHistory(this.videoUUID, currentTime) | 252 | saveVideoWatchHistory(this.options.videoUUID(), currentTime) |
219 | } | 253 | } |
220 | 254 | ||
221 | if (!this.videoViewUrl) return Promise.resolve(true) | 255 | if (!this.videoViewUrl) return Promise.resolve(true) |
@@ -225,7 +259,7 @@ class PeerTubePlugin extends Plugin { | |||
225 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) | 259 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) |
226 | if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) | 260 | if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) |
227 | 261 | ||
228 | return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) | 262 | return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers }) |
229 | } | 263 | } |
230 | 264 | ||
231 | // --------------------------------------------------------------------------- | 265 | // --------------------------------------------------------------------------- |
@@ -279,18 +313,89 @@ class PeerTubePlugin extends Plugin { | |||
279 | } | 313 | } |
280 | 314 | ||
281 | private initCaptions () { | 315 | private initCaptions () { |
282 | for (const caption of this.videoCaptions) { | 316 | debugLogger('Init captions with current subtitle ' + this.currentSubtitle) |
317 | |||
318 | this.player.tech(true).clearTracks('text') | ||
319 | |||
320 | for (const caption of this.options.videoCaptions()) { | ||
283 | this.player.addRemoteTextTrack({ | 321 | this.player.addRemoteTextTrack({ |
284 | kind: 'captions', | 322 | kind: 'captions', |
285 | label: caption.label, | 323 | label: caption.label, |
286 | language: caption.language, | 324 | language: caption.language, |
287 | id: caption.language, | 325 | id: caption.language, |
288 | src: caption.src, | 326 | src: caption.src, |
289 | default: this.defaultSubtitle === caption.language | 327 | default: this.currentSubtitle === caption.language |
290 | }, false) | 328 | }, true) |
329 | } | ||
330 | |||
331 | this.player.trigger('captions-changed') | ||
332 | } | ||
333 | |||
334 | private updateControlBar () { | ||
335 | debugLogger('Updating control bar') | ||
336 | |||
337 | if (this.options.isLive()) { | ||
338 | this.getPlaybackRateButton().hide() | ||
339 | |||
340 | this.player.controlBar.getChild('progressControl').hide() | ||
341 | this.player.controlBar.getChild('currentTimeDisplay').hide() | ||
342 | this.player.controlBar.getChild('timeDivider').hide() | ||
343 | this.player.controlBar.getChild('durationDisplay').hide() | ||
344 | |||
345 | this.player.controlBar.getChild('peerTubeLiveDisplay').show() | ||
346 | } else { | ||
347 | this.getPlaybackRateButton().show() | ||
348 | |||
349 | this.player.controlBar.getChild('progressControl').show() | ||
350 | this.player.controlBar.getChild('currentTimeDisplay').show() | ||
351 | this.player.controlBar.getChild('timeDivider').show() | ||
352 | this.player.controlBar.getChild('durationDisplay').show() | ||
353 | |||
354 | this.player.controlBar.getChild('peerTubeLiveDisplay').hide() | ||
291 | } | 355 | } |
292 | 356 | ||
293 | this.player.trigger('captionsChanged') | 357 | if (this.options.videoCaptions().length === 0) { |
358 | this.getCaptionsButton().hide() | ||
359 | } else { | ||
360 | this.getCaptionsButton().show() | ||
361 | } | ||
362 | } | ||
363 | |||
364 | private handleStartStopTime () { | ||
365 | this.player.duration(this.options.videoDuration()) | ||
366 | |||
367 | if (this.stopTimeHandler) { | ||
368 | this.player.off('timeupdate', this.stopTimeHandler) | ||
369 | this.stopTimeHandler = undefined | ||
370 | } | ||
371 | |||
372 | // Prefer canplaythrough instead of canplay because Chrome has issues with the second one | ||
373 | this.player.one('canplaythrough', () => { | ||
374 | if (this.options.startTime()) { | ||
375 | debugLogger('Start the video at ' + this.options.startTime()) | ||
376 | |||
377 | this.player.currentTime(timeToInt(this.options.startTime())) | ||
378 | } | ||
379 | |||
380 | if (this.options.stopTime()) { | ||
381 | const stopTime = timeToInt(this.options.stopTime()) | ||
382 | |||
383 | this.stopTimeHandler = () => { | ||
384 | if (this.player.currentTime() <= stopTime) return | ||
385 | |||
386 | debugLogger('Stopping the video at ' + this.options.stopTime()) | ||
387 | |||
388 | // Time top stop | ||
389 | this.player.pause() | ||
390 | this.player.trigger('auto-stopped') | ||
391 | |||
392 | this.player.off('timeupdate', this.stopTimeHandler) | ||
393 | this.stopTimeHandler = undefined | ||
394 | } | ||
395 | |||
396 | this.player.on('timeupdate', this.stopTimeHandler) | ||
397 | } | ||
398 | }) | ||
294 | } | 399 | } |
295 | 400 | ||
296 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | 401 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 |
@@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin { | |||
314 | this.update() | 419 | this.update() |
315 | } | 420 | } |
316 | } | 421 | } |
422 | |||
423 | private getCaptionsButton () { | ||
424 | const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton | ||
425 | |||
426 | return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton | ||
427 | } | ||
428 | |||
429 | private getPlaybackRateButton () { | ||
430 | const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton | ||
431 | |||
432 | return settingsButton.menu.getChild('playbackRateMenuButton') | ||
433 | } | ||
434 | |||
435 | // We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB | ||
436 | private deleteLegacyIndexedDB () { | ||
437 | try { | ||
438 | if (typeof window.indexedDB === 'undefined') return | ||
439 | if (!window.indexedDB) return | ||
440 | if (typeof window.indexedDB.databases !== 'function') return | ||
441 | |||
442 | window.indexedDB.databases() | ||
443 | .then(databases => { | ||
444 | for (const db of databases) { | ||
445 | window.indexedDB.deleteDatabase(db.name) | ||
446 | } | ||
447 | }) | ||
448 | } catch (err) { | ||
449 | debugLogger('Cannot delete legacy indexed DB', err) | ||
450 | // Nothing to do | ||
451 | } | ||
452 | } | ||
317 | } | 453 | } |
318 | 454 | ||
319 | videojs.registerPlugin('peertube', PeerTubePlugin) | 455 | videojs.registerPlugin('peertube', PeerTubePlugin) |
diff --git a/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts new file mode 100644 index 000000000..b467e3637 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts | |||
@@ -0,0 +1,136 @@ | |||
1 | import { | ||
2 | NextPreviousVideoButtonOptions, | ||
3 | PeerTubeLinkButtonOptions, | ||
4 | PeerTubePlayerContructorOptions, | ||
5 | PeerTubePlayerLoadOptions, | ||
6 | TheaterButtonOptions | ||
7 | } from '../../types' | ||
8 | |||
9 | type ControlBarOptionsBuilderConstructorOptions = | ||
10 | Pick<PeerTubePlayerContructorOptions, 'peertubeLink' | 'instanceName' | 'theaterButton'> & | ||
11 | { | ||
12 | videoShortUUID: () => string | ||
13 | p2pEnabled: () => boolean | ||
14 | |||
15 | previousVideo: () => PeerTubePlayerLoadOptions['previousVideo'] | ||
16 | nextVideo: () => PeerTubePlayerLoadOptions['nextVideo'] | ||
17 | } | ||
18 | |||
19 | export class ControlBarOptionsBuilder { | ||
20 | |||
21 | constructor (private options: ControlBarOptionsBuilderConstructorOptions) { | ||
22 | } | ||
23 | |||
24 | getChildrenOptions () { | ||
25 | const children = { | ||
26 | ...this.getPreviousVideo(), | ||
27 | |||
28 | playToggle: {}, | ||
29 | |||
30 | ...this.getNextVideo(), | ||
31 | |||
32 | ...this.getTimeControls(), | ||
33 | |||
34 | ...this.getProgressControl(), | ||
35 | |||
36 | p2PInfoButton: {}, | ||
37 | muteToggle: {}, | ||
38 | volumeControl: {}, | ||
39 | |||
40 | ...this.getSettingsButton(), | ||
41 | |||
42 | ...this.getPeerTubeLinkButton(), | ||
43 | |||
44 | ...this.getTheaterButton(), | ||
45 | |||
46 | fullscreenToggle: {} | ||
47 | } | ||
48 | |||
49 | return children | ||
50 | } | ||
51 | |||
52 | private getSettingsButton () { | ||
53 | const settingEntries: string[] = [] | ||
54 | |||
55 | settingEntries.push('playbackRateMenuButton') | ||
56 | settingEntries.push('captionsButton') | ||
57 | settingEntries.push('resolutionMenuButton') | ||
58 | |||
59 | return { | ||
60 | settingsButton: { | ||
61 | setup: { | ||
62 | maxHeightOffset: 40 | ||
63 | }, | ||
64 | entries: settingEntries | ||
65 | } | ||
66 | } | ||
67 | } | ||
68 | |||
69 | private getTimeControls () { | ||
70 | return { | ||
71 | peerTubeLiveDisplay: {}, | ||
72 | |||
73 | currentTimeDisplay: {}, | ||
74 | timeDivider: {}, | ||
75 | durationDisplay: {} | ||
76 | } | ||
77 | } | ||
78 | |||
79 | private getProgressControl () { | ||
80 | return { | ||
81 | progressControl: { | ||
82 | children: { | ||
83 | seekBar: { | ||
84 | children: { | ||
85 | loadProgressBar: {}, | ||
86 | mouseTimeDisplay: {}, | ||
87 | playProgressBar: {} | ||
88 | } | ||
89 | } | ||
90 | } | ||
91 | } | ||
92 | } | ||
93 | } | ||
94 | |||
95 | private getPreviousVideo () { | ||
96 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
97 | type: 'previous', | ||
98 | handler: () => this.options.previousVideo().handler(), | ||
99 | isDisabled: () => !this.options.previousVideo().enabled, | ||
100 | isDisplayed: () => this.options.previousVideo().displayControlBarButton | ||
101 | } | ||
102 | |||
103 | return { previousVideoButton: buttonOptions } | ||
104 | } | ||
105 | |||
106 | private getNextVideo () { | ||
107 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
108 | type: 'next', | ||
109 | handler: () => this.options.nextVideo().handler(), | ||
110 | isDisabled: () => !this.options.nextVideo().enabled, | ||
111 | isDisplayed: () => this.options.nextVideo().displayControlBarButton | ||
112 | } | ||
113 | |||
114 | return { nextVideoButton: buttonOptions } | ||
115 | } | ||
116 | |||
117 | private getPeerTubeLinkButton () { | ||
118 | const options: PeerTubeLinkButtonOptions = { | ||
119 | isDisplayed: this.options.peertubeLink, | ||
120 | shortUUID: this.options.videoShortUUID, | ||
121 | instanceName: this.options.instanceName | ||
122 | } | ||
123 | |||
124 | return { peerTubeLinkButton: options } | ||
125 | } | ||
126 | |||
127 | private getTheaterButton () { | ||
128 | const options: TheaterButtonOptions = { | ||
129 | isDisplayed: () => this.options.theaterButton | ||
130 | } | ||
131 | |||
132 | return { | ||
133 | theaterButton: options | ||
134 | } | ||
135 | } | ||
136 | } | ||
diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts index 8091110bc..10df2db5d 100644 --- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts +++ b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts | |||
@@ -3,49 +3,61 @@ import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' | |||
3 | import { logger } from '@root-helpers/logger' | 3 | import { logger } from '@root-helpers/logger' |
4 | import { LiveVideoLatencyMode } from '@shared/models' | 4 | import { LiveVideoLatencyMode } from '@shared/models' |
5 | import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' | 5 | import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' |
6 | import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' | 6 | import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types' |
7 | import { PeertubePlayerManagerOptions } from '../../types/manager-options' | ||
8 | import { getRtcConfig, isSameOrigin } from '../common' | 7 | import { getRtcConfig, isSameOrigin } from '../common' |
9 | import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' | 8 | import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' |
10 | import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' | 9 | import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' |
11 | import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' | 10 | import { SegmentValidator } from '../p2p-media-loader/segment-validator' |
11 | |||
12 | type ConstructorOptions = | ||
13 | Pick<PeerTubePlayerContructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader'> & | ||
14 | Pick<PeerTubePlayerLoadOptions, 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' | | ||
15 | 'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'> | ||
12 | 16 | ||
13 | export class HLSOptionsBuilder { | 17 | export class HLSOptionsBuilder { |
14 | 18 | ||
15 | constructor ( | 19 | constructor ( |
16 | private options: PeertubePlayerManagerOptions, | 20 | private options: ConstructorOptions, |
17 | private p2pMediaLoaderModule?: any | 21 | private p2pMediaLoaderModule?: any |
18 | ) { | 22 | ) { |
19 | 23 | ||
20 | } | 24 | } |
21 | 25 | ||
22 | async getPluginOptions () { | 26 | async getPluginOptions () { |
23 | const commonOptions = this.options.common | 27 | const redundancyUrlManager = new RedundancyUrlManager(this.options.hls.redundancyBaseUrls) |
24 | 28 | const segmentValidator = new SegmentValidator({ | |
25 | const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) | 29 | segmentsSha256Url: this.options.hls.segmentsSha256Url, |
30 | authorizationHeader: this.options.authorizationHeader, | ||
31 | requiresUserAuth: this.options.requiresUserAuth, | ||
32 | serverUrl: this.options.serverUrl, | ||
33 | requiresPassword: this.options.requiresPassword, | ||
34 | videoPassword: this.options.videoPassword | ||
35 | }) | ||
26 | 36 | ||
27 | const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook( | 37 | const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook( |
28 | 'filter:internal.player.p2p-media-loader.options.result', | 38 | 'filter:internal.player.p2p-media-loader.options.result', |
29 | this.getP2PMediaLoaderOptions(redundancyUrlManager) | 39 | this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator }) |
30 | ) | 40 | ) |
31 | const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader | 41 | const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader |
32 | 42 | ||
33 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | 43 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { |
34 | requiresUserAuth: commonOptions.requiresUserAuth, | 44 | requiresUserAuth: this.options.requiresUserAuth, |
35 | videoFileToken: commonOptions.videoFileToken, | 45 | videoFileToken: this.options.videoFileToken, |
36 | 46 | ||
37 | redundancyUrlManager, | 47 | redundancyUrlManager, |
38 | type: 'application/x-mpegURL', | 48 | type: 'application/x-mpegURL', |
39 | startTime: commonOptions.startTime, | 49 | src: this.options.hls.playlistUrl, |
40 | src: this.options.p2pMediaLoader.playlistUrl, | 50 | segmentValidator, |
41 | loader | 51 | loader |
42 | } | 52 | } |
43 | 53 | ||
44 | const hlsjs = { | 54 | const hlsjs = { |
55 | hlsjsConfig: this.getHLSJSOptions(loader), | ||
56 | |||
45 | levelLabelHandler: (level: { height: number, width: number }) => { | 57 | levelLabelHandler: (level: { height: number, width: number }) => { |
46 | const resolution = Math.min(level.height || 0, level.width || 0) | 58 | const resolution = Math.min(level.height || 0, level.width || 0) |
47 | 59 | ||
48 | const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution) | 60 | const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution) |
49 | // We don't have files for live videos | 61 | // We don't have files for live videos |
50 | if (!file) return level.height | 62 | if (!file) return level.height |
51 | 63 | ||
@@ -56,26 +68,27 @@ export class HLSOptionsBuilder { | |||
56 | } | 68 | } |
57 | } | 69 | } |
58 | 70 | ||
59 | const html5 = { | 71 | return { p2pMediaLoader, hlsjs } |
60 | hlsjsConfig: this.getHLSJSOptions(loader) | ||
61 | } | ||
62 | |||
63 | return { p2pMediaLoader, hlsjs, html5 } | ||
64 | } | 72 | } |
65 | 73 | ||
66 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
67 | 75 | ||
68 | private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { | 76 | private getP2PMediaLoaderOptions (options: { |
77 | redundancyUrlManager: RedundancyUrlManager | ||
78 | segmentValidator: SegmentValidator | ||
79 | }): HlsJsEngineSettings { | ||
80 | const { redundancyUrlManager, segmentValidator } = options | ||
81 | |||
69 | let consumeOnly = false | 82 | let consumeOnly = false |
70 | if ((navigator as any)?.connection?.type === 'cellular') { | 83 | if ((navigator as any)?.connection?.type === 'cellular') { |
71 | logger.info('We are on a cellular connection: disabling seeding.') | 84 | logger.info('We are on a cellular connection: disabling seeding.') |
72 | consumeOnly = true | 85 | consumeOnly = true |
73 | } | 86 | } |
74 | 87 | ||
75 | const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce | 88 | const trackerAnnounce = this.options.hls.trackerAnnounce |
76 | .filter(t => t.startsWith('ws')) | 89 | .filter(t => t.startsWith('ws')) |
77 | 90 | ||
78 | const specificLiveOrVODOptions = this.options.common.isLive | 91 | const specificLiveOrVODOptions = this.options.isLive |
79 | ? this.getP2PMediaLoaderLiveOptions() | 92 | ? this.getP2PMediaLoaderLiveOptions() |
80 | : this.getP2PMediaLoaderVODOptions() | 93 | : this.getP2PMediaLoaderVODOptions() |
81 | 94 | ||
@@ -88,35 +101,28 @@ export class HLSOptionsBuilder { | |||
88 | httpFailedSegmentTimeout: 1000, | 101 | httpFailedSegmentTimeout: 1000, |
89 | 102 | ||
90 | xhrSetup: (xhr, url) => { | 103 | xhrSetup: (xhr, url) => { |
91 | const { requiresUserAuth, requiresPassword } = this.options.common | 104 | const { requiresUserAuth, requiresPassword } = this.options |
92 | 105 | ||
93 | if (!(requiresUserAuth || requiresPassword)) return | 106 | if (!(requiresUserAuth || requiresPassword)) return |
94 | 107 | ||
95 | if (!isSameOrigin(this.options.common.serverUrl, url)) return | 108 | if (!isSameOrigin(this.options.serverUrl, url)) return |
96 | 109 | ||
97 | if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword()) | 110 | if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword()) |
98 | 111 | ||
99 | else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) | 112 | else xhr.setRequestHeader('Authorization', this.options.authorizationHeader()) |
100 | }, | 113 | }, |
101 | 114 | ||
102 | segmentValidator: segmentValidatorFactory({ | 115 | segmentValidator: segmentValidator.validate.bind(segmentValidator), |
103 | segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, | ||
104 | authorizationHeader: this.options.common.authorizationHeader, | ||
105 | requiresUserAuth: this.options.common.requiresUserAuth, | ||
106 | serverUrl: this.options.common.serverUrl, | ||
107 | requiresPassword: this.options.common.requiresPassword, | ||
108 | videoPassword: this.options.common.videoPassword | ||
109 | }), | ||
110 | 116 | ||
111 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), | 117 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), |
112 | 118 | ||
113 | useP2P: this.options.common.p2pEnabled, | 119 | useP2P: this.options.p2pEnabled, |
114 | consumeOnly, | 120 | consumeOnly, |
115 | 121 | ||
116 | ...specificLiveOrVODOptions | 122 | ...specificLiveOrVODOptions |
117 | }, | 123 | }, |
118 | segments: { | 124 | segments: { |
119 | swarmId: this.options.p2pMediaLoader.playlistUrl, | 125 | swarmId: this.options.hls.playlistUrl, |
120 | forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 | 126 | forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 |
121 | } | 127 | } |
122 | } | 128 | } |
@@ -127,7 +133,7 @@ export class HLSOptionsBuilder { | |||
127 | requiredSegmentsPriority: 1 | 133 | requiredSegmentsPriority: 1 |
128 | } | 134 | } |
129 | 135 | ||
130 | const latencyMode = this.options.common.liveOptions.latencyMode | 136 | const latencyMode = this.options.liveOptions.latencyMode |
131 | 137 | ||
132 | switch (latencyMode) { | 138 | switch (latencyMode) { |
133 | case LiveVideoLatencyMode.SMALL_LATENCY: | 139 | case LiveVideoLatencyMode.SMALL_LATENCY: |
@@ -165,7 +171,7 @@ export class HLSOptionsBuilder { | |||
165 | // --------------------------------------------------------------------------- | 171 | // --------------------------------------------------------------------------- |
166 | 172 | ||
167 | private getHLSJSOptions (loader: P2PMediaLoader) { | 173 | private getHLSJSOptions (loader: P2PMediaLoader) { |
168 | const specificLiveOrVODOptions = this.options.common.isLive | 174 | const specificLiveOrVODOptions = this.options.isLive |
169 | ? this.getHLSLiveOptions() | 175 | ? this.getHLSLiveOptions() |
170 | : this.getHLSVODOptions() | 176 | : this.getHLSVODOptions() |
171 | 177 | ||
@@ -193,7 +199,7 @@ export class HLSOptionsBuilder { | |||
193 | } | 199 | } |
194 | 200 | ||
195 | private getHLSLiveOptions () { | 201 | private getHLSLiveOptions () { |
196 | const latencyMode = this.options.common.liveOptions.latencyMode | 202 | const latencyMode = this.options.liveOptions.latencyMode |
197 | 203 | ||
198 | switch (latencyMode) { | 204 | switch (latencyMode) { |
199 | case LiveVideoLatencyMode.SMALL_LATENCY: | 205 | case LiveVideoLatencyMode.SMALL_LATENCY: |
diff --git a/client/src/assets/player/shared/player-options-builder/index.ts b/client/src/assets/player/shared/player-options-builder/index.ts new file mode 100644 index 000000000..674754a94 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './control-bar-options-builder' | ||
2 | export * from './hls-options-builder' | ||
3 | export * from './web-video-options-builder' | ||
diff --git a/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts new file mode 100644 index 000000000..a3c3c3f27 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { PeerTubePlayerLoadOptions, WebVideoPluginOptions } from '../../types' | ||
2 | |||
3 | type ConstructorOptions = Pick<PeerTubePlayerLoadOptions, 'videoFileToken' | 'webVideo' | 'hls' | 'startTime'> | ||
4 | |||
5 | export class WebVideoOptionsBuilder { | ||
6 | |||
7 | constructor (private options: ConstructorOptions) { | ||
8 | |||
9 | } | ||
10 | |||
11 | getPluginOptions (): WebVideoPluginOptions { | ||
12 | return { | ||
13 | videoFileToken: this.options.videoFileToken, | ||
14 | |||
15 | videoFiles: this.options.webVideo.videoFiles.length !== 0 | ||
16 | ? this.options.webVideo.videoFiles | ||
17 | : this.options?.hls.videoFiles || [], | ||
18 | |||
19 | startTime: this.options.startTime | ||
20 | } | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/assets/player/shared/playlist/playlist-button.ts b/client/src/assets/player/shared/playlist/playlist-button.ts index 6cfaf4158..45cbb4899 100644 --- a/client/src/assets/player/shared/playlist/playlist-button.ts +++ b/client/src/assets/player/shared/playlist/playlist-button.ts | |||
@@ -8,8 +8,15 @@ class PlaylistButton extends ClickableComponent { | |||
8 | private playlistInfoElement: HTMLElement | 8 | private playlistInfoElement: HTMLElement |
9 | private wrapper: HTMLElement | 9 | private wrapper: HTMLElement |
10 | 10 | ||
11 | constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) { | 11 | options_: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions |
12 | super(player, options as any) | 12 | |
13 | // FIXME: eslint -> it's not a useless constructor, we need to extend constructor options typings | ||
14 | // eslint-disable-next-line @typescript-eslint/no-useless-constructor | ||
15 | constructor ( | ||
16 | player: videojs.Player, | ||
17 | options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions | ||
18 | ) { | ||
19 | super(player, options) | ||
13 | } | 20 | } |
14 | 21 | ||
15 | createEl () { | 22 | createEl () { |
@@ -40,20 +47,15 @@ class PlaylistButton extends ClickableComponent { | |||
40 | } | 47 | } |
41 | 48 | ||
42 | update () { | 49 | update () { |
43 | const options = this.options_ as PlaylistPluginOptions | 50 | this.playlistInfoElement.innerHTML = this.options_.getCurrentPosition() + '/' + this.options_.playlist.videosLength |
44 | 51 | ||
45 | this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength | 52 | this.wrapper.title = this.player().localize('Playlist: {1}', [ this.options_.playlist.displayName ]) |
46 | this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ]) | ||
47 | } | 53 | } |
48 | 54 | ||
49 | handleClick () { | 55 | handleClick () { |
50 | const playlistMenu = this.getPlaylistMenu() | 56 | const playlistMenu = this.options_.playlistMenu |
51 | playlistMenu.open() | 57 | playlistMenu.open() |
52 | } | 58 | } |
53 | |||
54 | private getPlaylistMenu () { | ||
55 | return (this.options_ as any).playlistMenu as PlaylistMenu | ||
56 | } | ||
57 | } | 59 | } |
58 | 60 | ||
59 | videojs.registerComponent('PlaylistButton', PlaylistButton) | 61 | videojs.registerComponent('PlaylistButton', PlaylistButton) |
diff --git a/client/src/assets/player/shared/playlist/playlist-menu-item.ts b/client/src/assets/player/shared/playlist/playlist-menu-item.ts index 81b5acf30..f9366332d 100644 --- a/client/src/assets/player/shared/playlist/playlist-menu-item.ts +++ b/client/src/assets/player/shared/playlist/playlist-menu-item.ts | |||
@@ -8,6 +8,11 @@ const Component = videojs.getComponent('Component') | |||
8 | class PlaylistMenuItem extends Component { | 8 | class PlaylistMenuItem extends Component { |
9 | private element: VideoPlaylistElement | 9 | private element: VideoPlaylistElement |
10 | 10 | ||
11 | private clickHandler: () => void | ||
12 | private keyDownHandler: (event: KeyboardEvent) => void | ||
13 | |||
14 | options_: videojs.ComponentOptions & PlaylistItemOptions | ||
15 | |||
11 | constructor (player: videojs.Player, options?: PlaylistItemOptions) { | 16 | constructor (player: videojs.Player, options?: PlaylistItemOptions) { |
12 | super(player, options as any) | 17 | super(player, options as any) |
13 | 18 | ||
@@ -15,19 +20,27 @@ class PlaylistMenuItem extends Component { | |||
15 | 20 | ||
16 | this.element = options.element | 21 | this.element = options.element |
17 | 22 | ||
18 | this.on([ 'click', 'tap' ], () => this.switchPlaylistItem()) | 23 | this.clickHandler = () => this.switchPlaylistItem() |
19 | this.on('keydown', event => this.handleKeyDown(event)) | 24 | this.keyDownHandler = event => this.handleKeyDown(event) |
25 | |||
26 | this.on([ 'click', 'tap' ], this.clickHandler) | ||
27 | this.on('keydown', this.keyDownHandler) | ||
20 | } | 28 | } |
21 | 29 | ||
22 | createEl () { | 30 | dispose () { |
23 | const options = this.options_ as PlaylistItemOptions | 31 | this.off([ 'click', 'tap' ], this.clickHandler) |
32 | this.off('keydown', this.keyDownHandler) | ||
24 | 33 | ||
34 | super.dispose() | ||
35 | } | ||
36 | |||
37 | createEl () { | ||
25 | const li = super.createEl('li', { | 38 | const li = super.createEl('li', { |
26 | className: 'vjs-playlist-menu-item', | 39 | className: 'vjs-playlist-menu-item', |
27 | innerHTML: '' | 40 | innerHTML: '' |
28 | }) as HTMLElement | 41 | }) as HTMLElement |
29 | 42 | ||
30 | if (!options.element.video) { | 43 | if (!this.options_.element.video) { |
31 | li.classList.add('vjs-disabled') | 44 | li.classList.add('vjs-disabled') |
32 | } | 45 | } |
33 | 46 | ||
@@ -37,14 +50,14 @@ class PlaylistMenuItem extends Component { | |||
37 | 50 | ||
38 | const position = super.createEl('div', { | 51 | const position = super.createEl('div', { |
39 | className: 'item-position', | 52 | className: 'item-position', |
40 | innerHTML: options.element.position | 53 | innerHTML: this.options_.element.position |
41 | }) | 54 | }) |
42 | 55 | ||
43 | positionBlock.appendChild(position) | 56 | positionBlock.appendChild(position) |
44 | li.appendChild(positionBlock) | 57 | li.appendChild(positionBlock) |
45 | 58 | ||
46 | if (options.element.video) { | 59 | if (this.options_.element.video) { |
47 | this.buildAvailableVideo(li, positionBlock, options) | 60 | this.buildAvailableVideo(li, positionBlock, this.options_) |
48 | } else { | 61 | } else { |
49 | this.buildUnavailableVideo(li) | 62 | this.buildUnavailableVideo(li) |
50 | } | 63 | } |
@@ -125,9 +138,7 @@ class PlaylistMenuItem extends Component { | |||
125 | } | 138 | } |
126 | 139 | ||
127 | private switchPlaylistItem () { | 140 | private switchPlaylistItem () { |
128 | const options = this.options_ as PlaylistItemOptions | 141 | this.options_.onClicked() |
129 | |||
130 | options.onClicked() | ||
131 | } | 142 | } |
132 | } | 143 | } |
133 | 144 | ||
diff --git a/client/src/assets/player/shared/playlist/playlist-menu.ts b/client/src/assets/player/shared/playlist/playlist-menu.ts index 1ec9ac804..53a5a7274 100644 --- a/client/src/assets/player/shared/playlist/playlist-menu.ts +++ b/client/src/assets/player/shared/playlist/playlist-menu.ts | |||
@@ -6,26 +6,32 @@ import { PlaylistMenuItem } from './playlist-menu-item' | |||
6 | const Component = videojs.getComponent('Component') | 6 | const Component = videojs.getComponent('Component') |
7 | 7 | ||
8 | class PlaylistMenu extends Component { | 8 | class PlaylistMenu extends Component { |
9 | private menuItems: PlaylistMenuItem[] | 9 | private menuItems: PlaylistMenuItem[] = [] |
10 | 10 | ||
11 | constructor (player: videojs.Player, options?: PlaylistPluginOptions) { | 11 | private readonly userInactiveHandler: () => void |
12 | super(player, options as any) | 12 | private readonly onMouseEnter: () => void |
13 | private readonly onMouseLeave: () => void | ||
13 | 14 | ||
14 | const self = this | 15 | private readonly onPlayerCick: (event: Event) => void |
15 | 16 | ||
16 | function userInactiveHandler () { | 17 | options_: PlaylistPluginOptions & videojs.ComponentOptions |
17 | self.close() | 18 | |
19 | constructor (player: videojs.Player, options?: PlaylistPluginOptions & videojs.ComponentOptions) { | ||
20 | super(player, options) | ||
21 | |||
22 | this.userInactiveHandler = () => { | ||
23 | this.close() | ||
18 | } | 24 | } |
19 | 25 | ||
20 | this.el().addEventListener('mouseenter', () => { | 26 | this.onMouseEnter = () => { |
21 | this.player().off('userinactive', userInactiveHandler) | 27 | this.player().off('userinactive', this.userInactiveHandler) |
22 | }) | 28 | } |
23 | 29 | ||
24 | this.el().addEventListener('mouseleave', () => { | 30 | this.onMouseLeave = () => { |
25 | this.player().one('userinactive', userInactiveHandler) | 31 | this.player().one('userinactive', this.userInactiveHandler) |
26 | }) | 32 | } |
27 | 33 | ||
28 | this.player().on('click', event => { | 34 | this.onPlayerCick = event => { |
29 | let current = event.target as HTMLElement | 35 | let current = event.target as HTMLElement |
30 | 36 | ||
31 | do { | 37 | do { |
@@ -40,14 +46,31 @@ class PlaylistMenu extends Component { | |||
40 | } while (current) | 46 | } while (current) |
41 | 47 | ||
42 | this.close() | 48 | this.close() |
43 | }) | 49 | } |
50 | |||
51 | this.el().addEventListener('mouseenter', this.onMouseEnter) | ||
52 | this.el().addEventListener('mouseleave', this.onMouseLeave) | ||
53 | |||
54 | this.player().on('click', this.onPlayerCick) | ||
55 | } | ||
56 | |||
57 | dispose () { | ||
58 | this.el().removeEventListener('mouseenter', this.onMouseEnter) | ||
59 | this.el().removeEventListener('mouseleave', this.onMouseLeave) | ||
60 | |||
61 | this.player().off('userinactive', this.userInactiveHandler) | ||
62 | this.player().off('click', this.onPlayerCick) | ||
63 | |||
64 | for (const item of this.menuItems) { | ||
65 | item.dispose() | ||
66 | } | ||
67 | |||
68 | super.dispose() | ||
44 | } | 69 | } |
45 | 70 | ||
46 | createEl () { | 71 | createEl () { |
47 | this.menuItems = [] | 72 | this.menuItems = [] |
48 | 73 | ||
49 | const options = this.getOptions() | ||
50 | |||
51 | const menu = super.createEl('div', { | 74 | const menu = super.createEl('div', { |
52 | className: 'vjs-playlist-menu', | 75 | className: 'vjs-playlist-menu', |
53 | innerHTML: '', | 76 | innerHTML: '', |
@@ -61,11 +84,11 @@ class PlaylistMenu extends Component { | |||
61 | const headerLeft = super.createEl('div') | 84 | const headerLeft = super.createEl('div') |
62 | 85 | ||
63 | const leftTitle = super.createEl('div', { | 86 | const leftTitle = super.createEl('div', { |
64 | innerHTML: options.playlist.displayName, | 87 | innerHTML: this.options_.playlist.displayName, |
65 | className: 'title' | 88 | className: 'title' |
66 | }) | 89 | }) |
67 | 90 | ||
68 | const playlistChannel = options.playlist.videoChannel | 91 | const playlistChannel = this.options_.playlist.videoChannel |
69 | const leftSubtitle = super.createEl('div', { | 92 | const leftSubtitle = super.createEl('div', { |
70 | innerHTML: playlistChannel | 93 | innerHTML: playlistChannel |
71 | ? this.player().localize('By {1}', [ playlistChannel.displayName ]) | 94 | ? this.player().localize('By {1}', [ playlistChannel.displayName ]) |
@@ -86,7 +109,7 @@ class PlaylistMenu extends Component { | |||
86 | 109 | ||
87 | const list = super.createEl('ol') | 110 | const list = super.createEl('ol') |
88 | 111 | ||
89 | for (const playlistElement of options.elements) { | 112 | for (const playlistElement of this.options_.elements) { |
90 | const item = new PlaylistMenuItem(this.player(), { | 113 | const item = new PlaylistMenuItem(this.player(), { |
91 | element: playlistElement, | 114 | element: playlistElement, |
92 | onClicked: () => this.onItemClicked(playlistElement) | 115 | onClicked: () => this.onItemClicked(playlistElement) |
@@ -100,13 +123,13 @@ class PlaylistMenu extends Component { | |||
100 | menu.appendChild(header) | 123 | menu.appendChild(header) |
101 | menu.appendChild(list) | 124 | menu.appendChild(list) |
102 | 125 | ||
126 | this.update() | ||
127 | |||
103 | return menu | 128 | return menu |
104 | } | 129 | } |
105 | 130 | ||
106 | update () { | 131 | update () { |
107 | const options = this.getOptions() | 132 | this.updateSelected(this.options_.getCurrentPosition()) |
108 | |||
109 | this.updateSelected(options.getCurrentPosition()) | ||
110 | } | 133 | } |
111 | 134 | ||
112 | open () { | 135 | open () { |
@@ -123,12 +146,8 @@ class PlaylistMenu extends Component { | |||
123 | } | 146 | } |
124 | } | 147 | } |
125 | 148 | ||
126 | private getOptions () { | ||
127 | return this.options_ as PlaylistPluginOptions | ||
128 | } | ||
129 | |||
130 | private onItemClicked (element: VideoPlaylistElement) { | 149 | private onItemClicked (element: VideoPlaylistElement) { |
131 | this.getOptions().onItemClicked(element) | 150 | this.options_.onItemClicked(element) |
132 | } | 151 | } |
133 | } | 152 | } |
134 | 153 | ||
diff --git a/client/src/assets/player/shared/playlist/playlist-plugin.ts b/client/src/assets/player/shared/playlist/playlist-plugin.ts index 44de0da5a..c00e45843 100644 --- a/client/src/assets/player/shared/playlist/playlist-plugin.ts +++ b/client/src/assets/player/shared/playlist/playlist-plugin.ts | |||
@@ -8,17 +8,10 @@ const Plugin = videojs.getPlugin('plugin') | |||
8 | class PlaylistPlugin extends Plugin { | 8 | class PlaylistPlugin extends Plugin { |
9 | private playlistMenu: PlaylistMenu | 9 | private playlistMenu: PlaylistMenu |
10 | private playlistButton: PlaylistButton | 10 | private playlistButton: PlaylistButton |
11 | private options: PlaylistPluginOptions | ||
12 | 11 | ||
13 | constructor (player: videojs.Player, options?: PlaylistPluginOptions) { | 12 | constructor (player: videojs.Player, options?: PlaylistPluginOptions) { |
14 | super(player, options) | 13 | super(player, options) |
15 | 14 | ||
16 | this.options = options | ||
17 | |||
18 | this.player.ready(() => { | ||
19 | player.addClass('vjs-playlist') | ||
20 | }) | ||
21 | |||
22 | this.playlistMenu = new PlaylistMenu(player, options) | 15 | this.playlistMenu = new PlaylistMenu(player, options) |
23 | this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) | 16 | this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) |
24 | 17 | ||
@@ -26,8 +19,16 @@ class PlaylistPlugin extends Plugin { | |||
26 | player.addChild(this.playlistButton, options) | 19 | player.addChild(this.playlistButton, options) |
27 | } | 20 | } |
28 | 21 | ||
29 | updateSelected () { | 22 | dispose () { |
30 | this.playlistMenu.updateSelected(this.options.getCurrentPosition()) | 23 | this.player.removeClass('vjs-playlist') |
24 | |||
25 | this.playlistMenu.dispose() | ||
26 | this.playlistButton.dispose() | ||
27 | |||
28 | this.player.removeChild(this.playlistMenu) | ||
29 | this.player.removeChild(this.playlistButton) | ||
30 | |||
31 | super.dispose() | ||
31 | } | 32 | } |
32 | } | 33 | } |
33 | 34 | ||
diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts index 4fafd27b1..4d6701003 100644 --- a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts +++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts | |||
@@ -8,7 +8,16 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
8 | private resolutions: PeerTubeResolution[] = [] | 8 | private resolutions: PeerTubeResolution[] = [] |
9 | 9 | ||
10 | private autoResolutionChosenId: number | 10 | private autoResolutionChosenId: number |
11 | private autoResolutionEnabled = true | 11 | |
12 | constructor (player: videojs.Player) { | ||
13 | super(player) | ||
14 | |||
15 | player.on('video-change', () => { | ||
16 | this.resolutions = [] | ||
17 | |||
18 | this.trigger('resolutions-removed') | ||
19 | }) | ||
20 | } | ||
12 | 21 | ||
13 | add (resolutions: PeerTubeResolution[]) { | 22 | add (resolutions: PeerTubeResolution[]) { |
14 | for (const r of resolutions) { | 23 | for (const r of resolutions) { |
@@ -18,12 +27,12 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
18 | this.currentSelection = this.getSelected() | 27 | this.currentSelection = this.getSelected() |
19 | 28 | ||
20 | this.sort() | 29 | this.sort() |
21 | this.trigger('resolutionsAdded') | 30 | this.trigger('resolutions-added') |
22 | } | 31 | } |
23 | 32 | ||
24 | remove (resolutionIndex: number) { | 33 | remove (resolutionIndex: number) { |
25 | this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) | 34 | this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) |
26 | this.trigger('resolutionRemoved') | 35 | this.trigger('resolutions-removed') |
27 | } | 36 | } |
28 | 37 | ||
29 | getResolutions () { | 38 | getResolutions () { |
@@ -40,10 +49,10 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
40 | 49 | ||
41 | select (options: { | 50 | select (options: { |
42 | id: number | 51 | id: number |
43 | byEngine: boolean | 52 | fireCallback: boolean |
44 | autoResolutionChosenId?: number | 53 | autoResolutionChosenId?: number |
45 | }) { | 54 | }) { |
46 | const { id, autoResolutionChosenId, byEngine } = options | 55 | const { id, autoResolutionChosenId, fireCallback } = options |
47 | 56 | ||
48 | if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return | 57 | if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return |
49 | 58 | ||
@@ -55,25 +64,11 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
55 | if (r.selected) { | 64 | if (r.selected) { |
56 | this.currentSelection = r | 65 | this.currentSelection = r |
57 | 66 | ||
58 | if (!byEngine) r.selectCallback() | 67 | if (fireCallback) r.selectCallback() |
59 | } | 68 | } |
60 | } | 69 | } |
61 | 70 | ||
62 | this.trigger('resolutionChanged') | 71 | this.trigger('resolutions-changed') |
63 | } | ||
64 | |||
65 | disableAutoResolution () { | ||
66 | this.autoResolutionEnabled = false | ||
67 | this.trigger('autoResolutionEnabledChanged') | ||
68 | } | ||
69 | |||
70 | enabledAutoResolution () { | ||
71 | this.autoResolutionEnabled = true | ||
72 | this.trigger('autoResolutionEnabledChanged') | ||
73 | } | ||
74 | |||
75 | isAutoResolutionEnabeld () { | ||
76 | return this.autoResolutionEnabled | ||
77 | } | 72 | } |
78 | 73 | ||
79 | private sort () { | 74 | private sort () { |
diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts index 672411c11..c39894284 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-button.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts | |||
@@ -11,12 +11,12 @@ class ResolutionMenuButton extends MenuButton { | |||
11 | 11 | ||
12 | this.controlText('Quality') | 12 | this.controlText('Quality') |
13 | 13 | ||
14 | player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) | 14 | player.peertubeResolutions().on('resolutions-added', () => this.update()) |
15 | player.peertubeResolutions().on('resolutionRemoved', () => this.cleanupQualities()) | 15 | player.peertubeResolutions().on('resolutions-removed', () => this.update()) |
16 | 16 | ||
17 | // For parent | 17 | // For parent |
18 | player.peertubeResolutions().on('resolutionChanged', () => { | 18 | player.peertubeResolutions().on('resolutions-changed', () => { |
19 | setTimeout(() => this.trigger('labelUpdated')) | 19 | setTimeout(() => this.trigger('label-updated')) |
20 | }) | 20 | }) |
21 | } | 21 | } |
22 | 22 | ||
@@ -37,69 +37,42 @@ class ResolutionMenuButton extends MenuButton { | |||
37 | } | 37 | } |
38 | 38 | ||
39 | createMenu () { | 39 | createMenu () { |
40 | return new Menu(this.player_) | 40 | const menu: videojs.Menu = new Menu(this.player_, { menuButton: this }) |
41 | } | 41 | const resolutions = this.player().peertubeResolutions().getResolutions() |
42 | |||
43 | buildCSSClass () { | ||
44 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
45 | } | ||
46 | |||
47 | buildWrapperCSSClass () { | ||
48 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
49 | } | ||
50 | |||
51 | private addClickListener (component: any) { | ||
52 | component.on('click', () => { | ||
53 | const children = this.menu.children() | ||
54 | |||
55 | for (const child of children) { | ||
56 | if (component !== child) { | ||
57 | (child as videojs.MenuItem).selected(false) | ||
58 | } | ||
59 | } | ||
60 | }) | ||
61 | } | ||
62 | 42 | ||
63 | private buildQualities () { | 43 | for (const r of resolutions) { |
64 | for (const d of this.player().peertubeResolutions().getResolutions()) { | 44 | const label = r.label === '0p' |
65 | const label = d.label === '0p' | ||
66 | ? this.player().localize('Audio-only') | 45 | ? this.player().localize('Audio-only') |
67 | : d.label | 46 | : r.label |
68 | 47 | ||
69 | this.menu.addChild(new ResolutionMenuItem( | 48 | const component = new ResolutionMenuItem( |
70 | this.player_, | 49 | this.player_, |
71 | { | 50 | { |
72 | id: d.id + '', | 51 | id: r.id + '', |
73 | resolutionId: d.id, | 52 | resolutionId: r.id, |
74 | label, | 53 | label, |
75 | selected: d.selected | 54 | selected: r.selected |
76 | }) | 55 | } |
77 | ) | 56 | ) |
78 | } | ||
79 | 57 | ||
80 | for (const m of this.menu.children()) { | 58 | menu.addItem(component) |
81 | this.addClickListener(m) | ||
82 | } | 59 | } |
83 | 60 | ||
84 | this.trigger('menuChanged') | 61 | return menu |
85 | } | 62 | } |
86 | 63 | ||
87 | private cleanupQualities () { | 64 | update () { |
88 | const resolutions = this.player().peertubeResolutions().getResolutions() | 65 | super.update() |
89 | |||
90 | this.menu.children().forEach((children: ResolutionMenuItem) => { | ||
91 | if (children.resolutionId === undefined) { | ||
92 | return | ||
93 | } | ||
94 | 66 | ||
95 | if (resolutions.find(r => r.id === children.resolutionId)) { | 67 | this.trigger('menu-changed') |
96 | return | 68 | } |
97 | } | ||
98 | 69 | ||
99 | this.menu.removeChild(children) | 70 | buildCSSClass () { |
100 | }) | 71 | return super.buildCSSClass() + ' vjs-resolution-button' |
72 | } | ||
101 | 73 | ||
102 | this.trigger('menuChanged') | 74 | buildWrapperCSSClass () { |
75 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
103 | } | 76 | } |
104 | } | 77 | } |
105 | 78 | ||
diff --git a/client/src/assets/player/shared/settings/resolution-menu-item.ts b/client/src/assets/player/shared/settings/resolution-menu-item.ts index c59b8b891..86387f533 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-item.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-item.ts | |||
@@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem { | |||
10 | readonly resolutionId: number | 10 | readonly resolutionId: number |
11 | private readonly label: string | 11 | private readonly label: string |
12 | 12 | ||
13 | private autoResolutionEnabled: boolean | ||
14 | private autoResolutionChosen: string | 13 | private autoResolutionChosen: string |
15 | 14 | ||
16 | constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { | 15 | private updateSelectionHandler: () => void |
17 | options.selectable = true | ||
18 | 16 | ||
19 | super(player, options) | 17 | constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { |
18 | super(player, { ...options, selectable: true }) | ||
20 | 19 | ||
21 | this.autoResolutionEnabled = true | ||
22 | this.autoResolutionChosen = '' | 20 | this.autoResolutionChosen = '' |
23 | 21 | ||
24 | this.resolutionId = options.resolutionId | 22 | this.resolutionId = options.resolutionId |
25 | this.label = options.label | 23 | this.label = options.label |
26 | 24 | ||
27 | player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection()) | 25 | this.updateSelectionHandler = () => this.updateSelection() |
26 | player.peertubeResolutions().on('resolutions-changed', this.updateSelectionHandler) | ||
27 | } | ||
28 | |||
29 | dispose () { | ||
30 | this.player().peertubeResolutions().off('resolutions-changed', this.updateSelectionHandler) | ||
28 | 31 | ||
29 | // We only want to disable the "Auto" item | 32 | super.dispose() |
30 | if (this.resolutionId === -1) { | ||
31 | player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution()) | ||
32 | } | ||
33 | } | 33 | } |
34 | 34 | ||
35 | handleClick (event: any) { | 35 | handleClick (event: any) { |
36 | // Auto button disabled? | ||
37 | if (this.autoResolutionEnabled === false && this.resolutionId === -1) return | ||
38 | |||
39 | super.handleClick(event) | 36 | super.handleClick(event) |
40 | 37 | ||
41 | this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) | 38 | this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true }) |
42 | } | 39 | } |
43 | 40 | ||
44 | updateSelection () { | 41 | updateSelection () { |
@@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem { | |||
51 | this.selected(this.resolutionId === selectedResolution.id) | 48 | this.selected(this.resolutionId === selectedResolution.id) |
52 | } | 49 | } |
53 | 50 | ||
54 | updateAutoResolution () { | ||
55 | const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld() | ||
56 | |||
57 | // Check if the auto resolution is enabled or not | ||
58 | if (enabled === false) { | ||
59 | this.addClass('disabled') | ||
60 | } else { | ||
61 | this.removeClass('disabled') | ||
62 | } | ||
63 | |||
64 | this.autoResolutionEnabled = enabled | ||
65 | } | ||
66 | |||
67 | getLabel () { | 51 | getLabel () { |
68 | if (this.resolutionId === -1) { | 52 | if (this.resolutionId === -1) { |
69 | return this.label + ' <small>' + this.autoResolutionChosen + '</small>' | 53 | return this.label + ' <small>' + this.autoResolutionChosen + '</small>' |
diff --git a/client/src/assets/player/shared/settings/settings-dialog.ts b/client/src/assets/player/shared/settings/settings-dialog.ts index f5fbbe7ad..ba39d0f45 100644 --- a/client/src/assets/player/shared/settings/settings-dialog.ts +++ b/client/src/assets/player/shared/settings/settings-dialog.ts | |||
@@ -28,6 +28,18 @@ class SettingsDialog extends Component { | |||
28 | 'aria-describedby': dialogDescriptionId | 28 | 'aria-describedby': dialogDescriptionId |
29 | }) | 29 | }) |
30 | } | 30 | } |
31 | |||
32 | show () { | ||
33 | this.player().addClass('vjs-settings-dialog-opened') | ||
34 | |||
35 | super.show() | ||
36 | } | ||
37 | |||
38 | hide () { | ||
39 | this.player().removeClass('vjs-settings-dialog-opened') | ||
40 | |||
41 | super.hide() | ||
42 | } | ||
31 | } | 43 | } |
32 | 44 | ||
33 | Component.registerComponent('SettingsDialog', SettingsDialog) | 45 | Component.registerComponent('SettingsDialog', SettingsDialog) |
diff --git a/client/src/assets/player/shared/settings/settings-menu-button.ts b/client/src/assets/player/shared/settings/settings-menu-button.ts index 4cf29866b..9499a43eb 100644 --- a/client/src/assets/player/shared/settings/settings-menu-button.ts +++ b/client/src/assets/player/shared/settings/settings-menu-button.ts | |||
@@ -71,7 +71,7 @@ class SettingsButton extends Button { | |||
71 | } | 71 | } |
72 | } | 72 | } |
73 | 73 | ||
74 | onDisposeSettingsItem (event: any, name: string) { | 74 | onDisposeSettingsItem (_event: any, name: string) { |
75 | if (name === undefined) { | 75 | if (name === undefined) { |
76 | const children = this.menu.children() | 76 | const children = this.menu.children() |
77 | 77 | ||
@@ -103,6 +103,8 @@ class SettingsButton extends Button { | |||
103 | if (this.isInIframe()) { | 103 | if (this.isInIframe()) { |
104 | window.removeEventListener('blur', this.documentClickHandler) | 104 | window.removeEventListener('blur', this.documentClickHandler) |
105 | } | 105 | } |
106 | |||
107 | super.dispose() | ||
106 | } | 108 | } |
107 | 109 | ||
108 | onAddSettingsItem (event: any, data: any) { | 110 | onAddSettingsItem (event: any, data: any) { |
@@ -249,8 +251,8 @@ class SettingsButton extends Button { | |||
249 | } | 251 | } |
250 | 252 | ||
251 | resetChildren () { | 253 | resetChildren () { |
252 | for (const menuChild of this.menu.children()) { | 254 | for (const menuChild of this.menu.children() as SettingsMenuItem[]) { |
253 | (menuChild as SettingsMenuItem).reset() | 255 | menuChild.reset() |
254 | } | 256 | } |
255 | } | 257 | } |
256 | 258 | ||
@@ -258,8 +260,8 @@ class SettingsButton extends Button { | |||
258 | * Hide all the sub menus | 260 | * Hide all the sub menus |
259 | */ | 261 | */ |
260 | hideChildren () { | 262 | hideChildren () { |
261 | for (const menuChild of this.menu.children()) { | 263 | for (const menuChild of this.menu.children() as SettingsMenuItem[]) { |
262 | (menuChild as SettingsMenuItem).hideSubMenu() | 264 | menuChild.hideSubMenu() |
263 | } | 265 | } |
264 | } | 266 | } |
265 | 267 | ||
diff --git a/client/src/assets/player/shared/settings/settings-menu-item.ts b/client/src/assets/player/shared/settings/settings-menu-item.ts index 288e3b233..9916ae27f 100644 --- a/client/src/assets/player/shared/settings/settings-menu-item.ts +++ b/client/src/assets/player/shared/settings/settings-menu-item.ts | |||
@@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem { | |||
70 | this.build() | 70 | this.build() |
71 | 71 | ||
72 | // Update on rate change | 72 | // Update on rate change |
73 | player.on('ratechange', this.submenuClickHandler) | 73 | if (subMenuName === 'PlaybackRateMenuButton') { |
74 | player.on('ratechange', this.submenuClickHandler) | ||
75 | } | ||
74 | 76 | ||
75 | if (subMenuName === 'CaptionsButton') { | 77 | if (subMenuName === 'CaptionsButton') { |
76 | // Hack to regenerate captions on HTTP fallback | 78 | player.on('captions-changed', () => { |
77 | player.on('captionsChanged', () => { | 79 | // Wait menu component rebuild |
78 | setTimeout(() => { | 80 | setTimeout(() => { |
79 | this.settingsSubMenuEl_.innerHTML = '' | 81 | this.rebuildAfterMenuChange() |
80 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) | 82 | }, 150) |
81 | this.update() | 83 | }) |
82 | this.bindClickEvents() | 84 | } |
83 | }, 0) | 85 | |
86 | if (subMenuName === 'ResolutionMenuButton') { | ||
87 | this.subMenu.on('menu-changed', () => { | ||
88 | this.rebuildAfterMenuChange() | ||
84 | }) | 89 | }) |
85 | } | 90 | } |
86 | 91 | ||
@@ -89,6 +94,12 @@ class SettingsMenuItem extends MenuItem { | |||
89 | }) | 94 | }) |
90 | } | 95 | } |
91 | 96 | ||
97 | dispose () { | ||
98 | this.settingsSubMenuEl_.removeEventListener('transitionend', this.transitionEndHandler) | ||
99 | |||
100 | super.dispose() | ||
101 | } | ||
102 | |||
92 | eventHandlers () { | 103 | eventHandlers () { |
93 | this.submenuClickHandler = this.onSubmenuClick.bind(this) | 104 | this.submenuClickHandler = this.onSubmenuClick.bind(this) |
94 | this.transitionEndHandler = this.onTransitionEnd.bind(this) | 105 | this.transitionEndHandler = this.onTransitionEnd.bind(this) |
@@ -190,27 +201,6 @@ class SettingsMenuItem extends MenuItem { | |||
190 | (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) | 201 | (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) |
191 | } | 202 | } |
192 | 203 | ||
193 | /** | ||
194 | * Add/remove prefixed event listener for CSS Transition | ||
195 | * | ||
196 | * @method PrefixedEvent | ||
197 | */ | ||
198 | PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { | ||
199 | const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ] | ||
200 | |||
201 | for (let p = 0; p < prefix.length; p++) { | ||
202 | if (!prefix[p]) { | ||
203 | type = type.toLowerCase() | ||
204 | } | ||
205 | |||
206 | if (action === 'addEvent') { | ||
207 | element.addEventListener(prefix[p] + type, callback, false) | ||
208 | } else if (action === 'removeEvent') { | ||
209 | element.removeEventListener(prefix[p] + type, callback, false) | ||
210 | } | ||
211 | } | ||
212 | } | ||
213 | |||
214 | onTransitionEnd (event: any) { | 204 | onTransitionEnd (event: any) { |
215 | if (event.propertyName !== 'margin-right') { | 205 | if (event.propertyName !== 'margin-right') { |
216 | return | 206 | return |
@@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem { | |||
254 | } | 244 | } |
255 | 245 | ||
256 | build () { | 246 | build () { |
257 | this.subMenu.on('labelUpdated', () => { | 247 | this.subMenu.on('label-updated', () => { |
258 | this.update() | ||
259 | }) | ||
260 | this.subMenu.on('menuChanged', () => { | ||
261 | this.bindClickEvents() | ||
262 | this.setSize() | ||
263 | this.update() | 248 | this.update() |
264 | }) | 249 | }) |
265 | 250 | ||
@@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem { | |||
272 | this.setSize() | 257 | this.setSize() |
273 | this.bindClickEvents() | 258 | this.bindClickEvents() |
274 | 259 | ||
275 | // prefixed event listeners for CSS TransitionEnd | 260 | this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false) |
276 | this.PrefixedEvent( | ||
277 | this.settingsSubMenuEl_, | ||
278 | 'TransitionEnd', | ||
279 | this.transitionEndHandler, | ||
280 | 'addEvent' | ||
281 | ) | ||
282 | } | 261 | } |
283 | 262 | ||
284 | update (event?: any) { | 263 | update (event?: any) { |
285 | let target: HTMLElement = null | ||
286 | const subMenu = this.subMenu.name() | 264 | const subMenu = this.subMenu.name() |
287 | 265 | ||
288 | if (event && event.type === 'tap') { | ||
289 | target = event.target | ||
290 | } else if (event) { | ||
291 | target = event.currentTarget | ||
292 | } | ||
293 | |||
294 | // Playback rate menu button doesn't get a vjs-selected class | 266 | // Playback rate menu button doesn't get a vjs-selected class |
295 | // or sets options_['selected'] on the selected playback rate. | 267 | // or sets options_['selected'] on the selected playback rate. |
296 | // Thus we get the submenu value based on the labelEl of playbackRateMenuButton | 268 | // Thus we get the submenu value based on the labelEl of playbackRateMenuButton |
@@ -321,6 +293,13 @@ class SettingsMenuItem extends MenuItem { | |||
321 | } | 293 | } |
322 | } | 294 | } |
323 | 295 | ||
296 | let target: HTMLElement = null | ||
297 | if (event && event.type === 'tap') { | ||
298 | target = event.target | ||
299 | } else if (event) { | ||
300 | target = event.currentTarget | ||
301 | } | ||
302 | |||
324 | if (target && !target.classList.contains('vjs-back-button')) { | 303 | if (target && !target.classList.contains('vjs-back-button')) { |
325 | this.settingsButton.hideDialog() | 304 | this.settingsButton.hideDialog() |
326 | } | 305 | } |
@@ -369,6 +348,15 @@ class SettingsMenuItem extends MenuItem { | |||
369 | } | 348 | } |
370 | } | 349 | } |
371 | 350 | ||
351 | private rebuildAfterMenuChange () { | ||
352 | this.settingsSubMenuEl_.innerHTML = '' | ||
353 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) | ||
354 | this.update() | ||
355 | this.createBackButton() | ||
356 | this.setSize() | ||
357 | this.bindClickEvents() | ||
358 | } | ||
359 | |||
372 | } | 360 | } |
373 | 361 | ||
374 | (SettingsMenuItem as any).prototype.contentElType = 'button' | 362 | (SettingsMenuItem as any).prototype.contentElType = 'button' |
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts index 471a5e46c..fad68cec9 100644 --- a/client/src/assets/player/shared/stats/stats-card.ts +++ b/client/src/assets/player/shared/stats/stats-card.ts | |||
@@ -7,7 +7,7 @@ import { bytes } from '../common' | |||
7 | interface StatsCardOptions extends videojs.ComponentOptions { | 7 | interface StatsCardOptions extends videojs.ComponentOptions { |
8 | videoUUID: string | 8 | videoUUID: string |
9 | videoIsLive: boolean | 9 | videoIsLive: boolean |
10 | mode: 'webtorrent' | 'p2p-media-loader' | 10 | mode: 'web-video' | 'p2p-media-loader' |
11 | p2pEnabled: boolean | 11 | p2pEnabled: boolean |
12 | } | 12 | } |
13 | 13 | ||
@@ -34,7 +34,7 @@ class StatsCard extends Component { | |||
34 | 34 | ||
35 | updateInterval: any | 35 | updateInterval: any |
36 | 36 | ||
37 | mode: 'webtorrent' | 'p2p-media-loader' | 37 | mode: 'web-video' | 'p2p-media-loader' |
38 | 38 | ||
39 | metadataStore: any = {} | 39 | metadataStore: any = {} |
40 | 40 | ||
@@ -63,6 +63,9 @@ class StatsCard extends Component { | |||
63 | 63 | ||
64 | private liveLatency: InfoElement | 64 | private liveLatency: InfoElement |
65 | 65 | ||
66 | private onP2PInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void | ||
67 | private onHTTPInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void | ||
68 | |||
66 | createEl () { | 69 | createEl () { |
67 | this.containerEl = videojs.dom.createEl('div', { | 70 | this.containerEl = videojs.dom.createEl('div', { |
68 | className: 'vjs-stats-content' | 71 | className: 'vjs-stats-content' |
@@ -86,9 +89,7 @@ class StatsCard extends Component { | |||
86 | 89 | ||
87 | this.populateInfoBlocks() | 90 | this.populateInfoBlocks() |
88 | 91 | ||
89 | this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { | 92 | this.onP2PInfoHandler = (_event, data) => { |
90 | if (!data) return // HTTP fallback | ||
91 | |||
92 | this.mode = data.source | 93 | this.mode = data.source |
93 | 94 | ||
94 | const p2pStats = data.p2p | 95 | const p2pStats = data.p2p |
@@ -105,11 +106,29 @@ class StatsCard extends Component { | |||
105 | this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') | 106 | this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') |
106 | this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') | 107 | this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') |
107 | } | 108 | } |
108 | }) | 109 | } |
110 | |||
111 | this.onHTTPInfoHandler = (_event, data) => { | ||
112 | this.mode = data.source | ||
113 | |||
114 | this.playerNetworkInfo.totalDownloaded = bytes(data.http.downloaded).join(' ') | ||
115 | } | ||
116 | |||
117 | this.player().on('p2p-info', this.onP2PInfoHandler) | ||
118 | this.player().on('http-info', this.onHTTPInfoHandler) | ||
109 | 119 | ||
110 | return this.containerEl | 120 | return this.containerEl |
111 | } | 121 | } |
112 | 122 | ||
123 | dispose () { | ||
124 | if (this.updateInterval) clearInterval(this.updateInterval) | ||
125 | |||
126 | this.player().off('p2p-info', this.onP2PInfoHandler) | ||
127 | this.player().off('http-info', this.onHTTPInfoHandler) | ||
128 | |||
129 | super.dispose() | ||
130 | } | ||
131 | |||
113 | toggle () { | 132 | toggle () { |
114 | if (this.updateInterval) this.hide() | 133 | if (this.updateInterval) this.hide() |
115 | else this.show() | 134 | else this.show() |
@@ -122,7 +141,7 @@ class StatsCard extends Component { | |||
122 | try { | 141 | try { |
123 | const options = this.mode === 'p2p-media-loader' | 142 | const options = this.mode === 'p2p-media-loader' |
124 | ? this.buildHLSOptions() | 143 | ? this.buildHLSOptions() |
125 | : await this.buildWebTorrentOptions() // Default | 144 | : await this.buildWebVideoOptions() // Default |
126 | 145 | ||
127 | this.populateInfoValues(options) | 146 | this.populateInfoValues(options) |
128 | } catch (err) { | 147 | } catch (err) { |
@@ -170,8 +189,8 @@ class StatsCard extends Component { | |||
170 | } | 189 | } |
171 | } | 190 | } |
172 | 191 | ||
173 | private async buildWebTorrentOptions () { | 192 | private async buildWebVideoOptions () { |
174 | const videoFile = this.player_.webtorrent().getCurrentVideoFile() | 193 | const videoFile = this.player_.webVideo().getCurrentVideoFile() |
175 | 194 | ||
176 | if (!this.metadataStore[videoFile.fileUrl]) { | 195 | if (!this.metadataStore[videoFile.fileUrl]) { |
177 | this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) | 196 | this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) |
@@ -194,7 +213,7 @@ class StatsCard extends Component { | |||
194 | 213 | ||
195 | const resolution = videoFile?.resolution.label + videoFile?.fps | 214 | const resolution = videoFile?.resolution.label + videoFile?.fps |
196 | const buffer = this.timeRangesToString(this.player_.buffered()) | 215 | const buffer = this.timeRangesToString(this.player_.buffered()) |
197 | const progress = this.player_.webtorrent().getTorrent()?.progress | 216 | const progress = this.player_.bufferedPercent() |
198 | 217 | ||
199 | return { | 218 | return { |
200 | playerNetworkInfo: this.playerNetworkInfo, | 219 | playerNetworkInfo: this.playerNetworkInfo, |
@@ -284,8 +303,10 @@ class StatsCard extends Component { | |||
284 | ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` | 303 | ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` |
285 | : undefined | 304 | : undefined |
286 | 305 | ||
287 | this.setInfoValue(this.playerMode, this.mode || 'HTTP') | 306 | const p2pEnabled = this.options_.p2pEnabled && this.mode === 'p2p-media-loader' |
288 | this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled')) | 307 | |
308 | this.setInfoValue(this.playerMode, this.mode) | ||
309 | this.setInfoValue(this.p2p, player.localize(p2pEnabled ? 'enabled' : 'disabled')) | ||
289 | this.setInfoValue(this.uuid, this.options_.videoUUID) | 310 | this.setInfoValue(this.uuid, this.options_.videoUUID) |
290 | 311 | ||
291 | this.setInfoValue(this.viewport, frames) | 312 | this.setInfoValue(this.viewport, frames) |
diff --git a/client/src/assets/player/shared/stats/stats-plugin.ts b/client/src/assets/player/shared/stats/stats-plugin.ts index 8aad80e8a..86684a78c 100644 --- a/client/src/assets/player/shared/stats/stats-plugin.ts +++ b/client/src/assets/player/shared/stats/stats-plugin.ts | |||
@@ -7,10 +7,6 @@ class StatsForNerdsPlugin extends Plugin { | |||
7 | private statsCard: StatsCard | 7 | private statsCard: StatsCard |
8 | 8 | ||
9 | constructor (player: videojs.Player, options: StatsCardOptions) { | 9 | constructor (player: videojs.Player, options: StatsCardOptions) { |
10 | const settings = { | ||
11 | ...options | ||
12 | } | ||
13 | |||
14 | super(player) | 10 | super(player) |
15 | 11 | ||
16 | this.player.ready(() => { | 12 | this.player.ready(() => { |
@@ -19,7 +15,17 @@ class StatsForNerdsPlugin extends Plugin { | |||
19 | 15 | ||
20 | this.statsCard = new StatsCard(player, options) | 16 | this.statsCard = new StatsCard(player, options) |
21 | 17 | ||
22 | player.addChild(this.statsCard, settings) | 18 | // Copy options |
19 | player.addChild(this.statsCard) | ||
20 | } | ||
21 | |||
22 | dispose () { | ||
23 | if (this.statsCard) { | ||
24 | this.statsCard.dispose() | ||
25 | this.player.removeChild(this.statsCard) | ||
26 | } | ||
27 | |||
28 | super.dispose() | ||
23 | } | 29 | } |
24 | 30 | ||
25 | show () { | 31 | show () { |
diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts index 61668e407..3589e1fd8 100644 --- a/client/src/assets/player/shared/upnext/end-card.ts +++ b/client/src/assets/player/shared/upnext/end-card.ts | |||
@@ -1,6 +1,7 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { UpNextPluginOptions } from '../../types' | ||
2 | 3 | ||
3 | function getMainTemplate (options: any) { | 4 | function getMainTemplate (options: EndCardOptions) { |
4 | return ` | 5 | return ` |
5 | <div class="vjs-upnext-top"> | 6 | <div class="vjs-upnext-top"> |
6 | <span class="vjs-upnext-headtext">${options.headText}</span> | 7 | <span class="vjs-upnext-headtext">${options.headText}</span> |
@@ -23,15 +24,10 @@ function getMainTemplate (options: any) { | |||
23 | ` | 24 | ` |
24 | } | 25 | } |
25 | 26 | ||
26 | export interface EndCardOptions extends videojs.ComponentOptions { | 27 | export interface EndCardOptions extends videojs.ComponentOptions, UpNextPluginOptions { |
27 | next: () => void | ||
28 | getTitle: () => string | ||
29 | timeout: number | ||
30 | cancelText: string | 28 | cancelText: string |
31 | headText: string | 29 | headText: string |
32 | suspendedText: string | 30 | suspendedText: string |
33 | condition: () => boolean | ||
34 | suspended: () => boolean | ||
35 | } | 31 | } |
36 | 32 | ||
37 | const Component = videojs.getComponent('Component') | 33 | const Component = videojs.getComponent('Component') |
@@ -52,27 +48,43 @@ class EndCard extends Component { | |||
52 | suspendedMessage: HTMLElement | 48 | suspendedMessage: HTMLElement |
53 | nextButton: HTMLElement | 49 | nextButton: HTMLElement |
54 | 50 | ||
51 | private onEndedHandler: () => void | ||
52 | private onPlayingHandler: () => void | ||
53 | |||
55 | constructor (player: videojs.Player, options: EndCardOptions) { | 54 | constructor (player: videojs.Player, options: EndCardOptions) { |
56 | super(player, options) | 55 | super(player, options) |
57 | 56 | ||
58 | this.totalTicks = this.options_.timeout / this.interval | 57 | this.totalTicks = this.options_.timeout / this.interval |
59 | 58 | ||
60 | player.on('ended', (_: any) => { | 59 | this.onEndedHandler = () => { |
61 | if (!this.options_.condition()) return | 60 | if (!this.options_.isDisplayed()) return |
62 | 61 | ||
63 | player.addClass('vjs-upnext--showing') | 62 | player.addClass('vjs-upnext--showing') |
64 | this.showCard((canceled: boolean) => { | 63 | |
64 | this.showCard(canceled => { | ||
65 | player.removeClass('vjs-upnext--showing') | 65 | player.removeClass('vjs-upnext--showing') |
66 | |||
66 | this.container.style.display = 'none' | 67 | this.container.style.display = 'none' |
68 | |||
67 | if (!canceled) { | 69 | if (!canceled) { |
68 | this.options_.next() | 70 | this.options_.next() |
69 | } | 71 | } |
70 | }) | 72 | }) |
71 | }) | 73 | } |
72 | 74 | ||
73 | player.on('playing', () => { | 75 | this.onPlayingHandler = () => { |
74 | this.upNextEvents.trigger('playing') | 76 | this.upNextEvents.trigger('playing') |
75 | }) | 77 | } |
78 | |||
79 | player.on([ 'auto-stopped', 'ended' ], this.onEndedHandler) | ||
80 | player.on('playing', this.onPlayingHandler) | ||
81 | } | ||
82 | |||
83 | dispose () { | ||
84 | if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler) | ||
85 | if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler) | ||
86 | |||
87 | super.dispose() | ||
76 | } | 88 | } |
77 | 89 | ||
78 | createEl () { | 90 | createEl () { |
@@ -101,7 +113,7 @@ class EndCard extends Component { | |||
101 | return container | 113 | return container |
102 | } | 114 | } |
103 | 115 | ||
104 | showCard (cb: (value: boolean) => void) { | 116 | showCard (cb: (canceled: boolean) => void) { |
105 | let timeout: any | 117 | let timeout: any |
106 | 118 | ||
107 | this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) | 119 | this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) |
@@ -109,6 +121,10 @@ class EndCard extends Component { | |||
109 | 121 | ||
110 | this.title.innerHTML = this.options_.getTitle() | 122 | this.title.innerHTML = this.options_.getTitle() |
111 | 123 | ||
124 | if (this.totalTicks === 0) { | ||
125 | return cb(false) | ||
126 | } | ||
127 | |||
112 | this.upNextEvents.one('cancel', () => { | 128 | this.upNextEvents.one('cancel', () => { |
113 | clearTimeout(timeout) | 129 | clearTimeout(timeout) |
114 | cb(true) | 130 | cb(true) |
@@ -134,7 +150,7 @@ class EndCard extends Component { | |||
134 | } | 150 | } |
135 | 151 | ||
136 | const update = () => { | 152 | const update = () => { |
137 | if (this.options_.suspended()) { | 153 | if (this.options_.isSuspended()) { |
138 | this.suspendedMessage.innerText = this.options_.suspendedText | 154 | this.suspendedMessage.innerText = this.options_.suspendedText |
139 | goToPercent(0) | 155 | goToPercent(0) |
140 | this.ticks = 0 | 156 | this.ticks = 0 |
diff --git a/client/src/assets/player/shared/upnext/upnext-plugin.ts b/client/src/assets/player/shared/upnext/upnext-plugin.ts index e12e8c503..0badcd68c 100644 --- a/client/src/assets/player/shared/upnext/upnext-plugin.ts +++ b/client/src/assets/player/shared/upnext/upnext-plugin.ts | |||
@@ -1,27 +1,25 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { UpNextPluginOptions } from '../../types' | ||
2 | import { EndCardOptions } from './end-card' | 3 | import { EndCardOptions } from './end-card' |
3 | 4 | ||
4 | const Plugin = videojs.getPlugin('plugin') | 5 | const Plugin = videojs.getPlugin('plugin') |
5 | 6 | ||
6 | class UpNextPlugin extends Plugin { | 7 | class UpNextPlugin extends Plugin { |
7 | 8 | ||
8 | constructor (player: videojs.Player, options: Partial<EndCardOptions> = {}) { | 9 | constructor (player: videojs.Player, options: UpNextPluginOptions) { |
9 | const settings = { | 10 | super(player) |
11 | |||
12 | const settings: EndCardOptions = { | ||
10 | next: options.next, | 13 | next: options.next, |
11 | getTitle: options.getTitle, | 14 | getTitle: options.getTitle, |
12 | timeout: options.timeout || 5000, | 15 | timeout: options.timeout, |
13 | cancelText: options.cancelText || 'Cancel', | 16 | cancelText: player.localize('Cancel'), |
14 | headText: options.headText || 'Up Next', | 17 | headText: player.localize('Up Next'), |
15 | suspendedText: options.suspendedText || 'Autoplay is suspended', | 18 | suspendedText: player.localize('Autoplay is suspended'), |
16 | condition: options.condition, | 19 | isDisplayed: options.isDisplayed, |
17 | suspended: options.suspended | 20 | isSuspended: options.isSuspended |
18 | } | 21 | } |
19 | 22 | ||
20 | super(player) | ||
21 | |||
22 | // UpNext plugin can be called later, so ensure the player is not disposed | ||
23 | if (this.player.isDisposed()) return | ||
24 | |||
25 | this.player.ready(() => { | 23 | this.player.ready(() => { |
26 | player.addClass('vjs-upnext') | 24 | player.addClass('vjs-upnext') |
27 | }) | 25 | }) |
diff --git a/client/src/assets/player/shared/web-video/web-video-plugin.ts b/client/src/assets/player/shared/web-video/web-video-plugin.ts new file mode 100644 index 000000000..80e56795b --- /dev/null +++ b/client/src/assets/player/shared/web-video/web-video-plugin.ts | |||
@@ -0,0 +1,186 @@ | |||
1 | import debug from 'debug' | ||
2 | import videojs from 'video.js' | ||
3 | import { logger } from '@root-helpers/logger' | ||
4 | import { addQueryParams } from '@shared/core-utils' | ||
5 | import { VideoFile } from '@shared/models' | ||
6 | import { PeerTubeResolution, PlayerNetworkInfo, WebVideoPluginOptions } from '../../types' | ||
7 | |||
8 | const debugLogger = debug('peertube:player:web-video-plugin') | ||
9 | |||
10 | const Plugin = videojs.getPlugin('plugin') | ||
11 | |||
12 | class WebVideoPlugin extends Plugin { | ||
13 | private readonly videoFiles: VideoFile[] | ||
14 | |||
15 | private currentVideoFile: VideoFile | ||
16 | private videoFileToken: () => string | ||
17 | |||
18 | private networkInfoInterval: any | ||
19 | |||
20 | private onErrorHandler: () => void | ||
21 | private onPlayHandler: () => void | ||
22 | |||
23 | constructor (player: videojs.Player, options?: WebVideoPluginOptions) { | ||
24 | super(player, options) | ||
25 | |||
26 | this.videoFiles = options.videoFiles | ||
27 | this.videoFileToken = options.videoFileToken | ||
28 | |||
29 | this.updateVideoFile({ videoFile: this.pickAverageVideoFile(), isUserResolutionChange: false }) | ||
30 | |||
31 | player.ready(() => { | ||
32 | this.buildQualities() | ||
33 | |||
34 | this.setupNetworkInfoInterval() | ||
35 | |||
36 | if (this.videoFiles.length === 0) { | ||
37 | this.player.addClass('disabled') | ||
38 | return | ||
39 | } | ||
40 | }) | ||
41 | } | ||
42 | |||
43 | dispose () { | ||
44 | clearInterval(this.networkInfoInterval) | ||
45 | |||
46 | if (this.onErrorHandler) this.player.off('error', this.onErrorHandler) | ||
47 | if (this.onPlayHandler) this.player.off('canplay', this.onPlayHandler) | ||
48 | |||
49 | super.dispose() | ||
50 | } | ||
51 | |||
52 | getCurrentResolutionId () { | ||
53 | return this.currentVideoFile.resolution.id | ||
54 | } | ||
55 | |||
56 | updateVideoFile (options: { | ||
57 | videoFile: VideoFile | ||
58 | isUserResolutionChange: boolean | ||
59 | }) { | ||
60 | this.currentVideoFile = options.videoFile | ||
61 | |||
62 | debugLogger('Updating web video file to ' + this.currentVideoFile.fileUrl) | ||
63 | |||
64 | const paused = this.player.paused() | ||
65 | const playbackRate = this.player.playbackRate() | ||
66 | const currentTime = this.player.currentTime() | ||
67 | |||
68 | // Enable error display now this is our last fallback | ||
69 | this.onErrorHandler = () => this.player.peertube().displayFatalError() | ||
70 | this.player.one('error', this.onErrorHandler) | ||
71 | |||
72 | let httpUrl = this.currentVideoFile.fileUrl | ||
73 | |||
74 | if (this.videoFileToken()) { | ||
75 | httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) | ||
76 | } | ||
77 | |||
78 | const oldAutoplayValue = this.player.autoplay() | ||
79 | if (options.isUserResolutionChange) { | ||
80 | this.player.autoplay(false) | ||
81 | this.player.addClass('vjs-updating-resolution') | ||
82 | } | ||
83 | |||
84 | this.player.src(httpUrl) | ||
85 | |||
86 | this.onPlayHandler = () => { | ||
87 | this.player.playbackRate(playbackRate) | ||
88 | this.player.currentTime(currentTime) | ||
89 | |||
90 | this.adaptPosterForAudioOnly() | ||
91 | |||
92 | if (options.isUserResolutionChange) { | ||
93 | this.player.trigger('user-resolution-change') | ||
94 | this.player.trigger('web-video-source-change') | ||
95 | |||
96 | this.tryToPlay() | ||
97 | .then(() => { | ||
98 | if (paused) this.player.pause() | ||
99 | |||
100 | this.player.autoplay(oldAutoplayValue) | ||
101 | }) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | this.player.one('canplay', this.onPlayHandler) | ||
106 | } | ||
107 | |||
108 | getCurrentVideoFile () { | ||
109 | return this.currentVideoFile | ||
110 | } | ||
111 | |||
112 | private adaptPosterForAudioOnly () { | ||
113 | // Audio-only (resolutionId === 0) gets special treatment | ||
114 | if (this.currentVideoFile.resolution.id === 0) { | ||
115 | this.player.audioPosterMode(true) | ||
116 | } else { | ||
117 | this.player.audioPosterMode(false) | ||
118 | } | ||
119 | } | ||
120 | |||
121 | private tryToPlay () { | ||
122 | debugLogger('Try to play manually the video') | ||
123 | |||
124 | const playPromise = this.player.play() | ||
125 | if (playPromise === undefined) return | ||
126 | |||
127 | return playPromise | ||
128 | .catch((err: Error) => { | ||
129 | if (err.message.includes('The play() request was interrupted by a call to pause()')) { | ||
130 | return | ||
131 | } | ||
132 | |||
133 | logger.warn(err) | ||
134 | this.player.pause() | ||
135 | this.player.posterImage.show() | ||
136 | this.player.removeClass('vjs-has-autoplay') | ||
137 | this.player.removeClass('vjs-playing-audio-only-content') | ||
138 | }) | ||
139 | .finally(() => { | ||
140 | this.player.removeClass('vjs-updating-resolution') | ||
141 | }) | ||
142 | } | ||
143 | |||
144 | private pickAverageVideoFile () { | ||
145 | if (this.videoFiles.length === 1) return this.videoFiles[0] | ||
146 | |||
147 | const files = this.videoFiles.filter(f => f.resolution.id !== 0) | ||
148 | return files[Math.floor(files.length / 2)] | ||
149 | } | ||
150 | |||
151 | private buildQualities () { | ||
152 | const resolutions: PeerTubeResolution[] = this.videoFiles.map(videoFile => ({ | ||
153 | id: videoFile.resolution.id, | ||
154 | label: this.buildQualityLabel(videoFile), | ||
155 | height: videoFile.resolution.id, | ||
156 | selected: videoFile.id === this.currentVideoFile.id, | ||
157 | selectCallback: () => this.updateVideoFile({ videoFile, isUserResolutionChange: true }) | ||
158 | })) | ||
159 | |||
160 | this.player.peertubeResolutions().add(resolutions) | ||
161 | } | ||
162 | |||
163 | private buildQualityLabel (file: VideoFile) { | ||
164 | let label = file.resolution.label | ||
165 | |||
166 | if (file.fps && file.fps >= 50) { | ||
167 | label += file.fps | ||
168 | } | ||
169 | |||
170 | return label | ||
171 | } | ||
172 | |||
173 | private setupNetworkInfoInterval () { | ||
174 | this.networkInfoInterval = setInterval(() => { | ||
175 | return this.player.trigger('http-info', { | ||
176 | source: 'web-video', | ||
177 | http: { | ||
178 | downloaded: this.player.bufferedPercent() * this.currentVideoFile.size | ||
179 | } | ||
180 | } as PlayerNetworkInfo) | ||
181 | }, 1000) | ||
182 | } | ||
183 | } | ||
184 | |||
185 | videojs.registerPlugin('webVideo', WebVideoPlugin) | ||
186 | export { WebVideoPlugin } | ||
diff --git a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts deleted file mode 100644 index 74ae17704..000000000 --- a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts +++ /dev/null | |||
@@ -1,234 +0,0 @@ | |||
1 | // From https://github.com/MinEduTDF/idb-chunk-store | ||
2 | // We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues | ||
3 | // Thanks @santiagogil and @Feross | ||
4 | |||
5 | import Dexie from 'dexie' | ||
6 | import { EventEmitter } from 'events' | ||
7 | import { logger } from '@root-helpers/logger' | ||
8 | |||
9 | class ChunkDatabase extends Dexie { | ||
10 | chunks: Dexie.Table<{ id: number, buf: Buffer }, number> | ||
11 | |||
12 | constructor (dbname: string) { | ||
13 | super(dbname) | ||
14 | |||
15 | this.version(1).stores({ | ||
16 | chunks: 'id' | ||
17 | }) | ||
18 | } | ||
19 | } | ||
20 | |||
21 | class ExpirationDatabase extends Dexie { | ||
22 | databases: Dexie.Table<{ name: string, expiration: number }, number> | ||
23 | |||
24 | constructor () { | ||
25 | super('webtorrent-expiration') | ||
26 | |||
27 | this.version(1).stores({ | ||
28 | databases: 'name,expiration' | ||
29 | }) | ||
30 | } | ||
31 | } | ||
32 | |||
33 | export class PeertubeChunkStore extends EventEmitter { | ||
34 | private static readonly BUFFERING_PUT_MS = 1000 | ||
35 | private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute | ||
36 | private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes | ||
37 | |||
38 | chunkLength: number | ||
39 | |||
40 | private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = [] | ||
41 | // If the store is full | ||
42 | private memoryChunks: { [ id: number ]: Buffer | true } = {} | ||
43 | private databaseName: string | ||
44 | private putBulkTimeout: any | ||
45 | private cleanerInterval: any | ||
46 | private db: ChunkDatabase | ||
47 | private expirationDB: ExpirationDatabase | ||
48 | private readonly length: number | ||
49 | private readonly lastChunkLength: number | ||
50 | private readonly lastChunkIndex: number | ||
51 | |||
52 | constructor (chunkLength: number, opts: any) { | ||
53 | super() | ||
54 | |||
55 | this.databaseName = 'webtorrent-chunks-' | ||
56 | |||
57 | if (!opts) opts = {} | ||
58 | if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash | ||
59 | else this.databaseName += '-default' | ||
60 | |||
61 | this.setMaxListeners(100) | ||
62 | |||
63 | this.chunkLength = Number(chunkLength) | ||
64 | if (!this.chunkLength) throw new Error('First argument must be a chunk length') | ||
65 | |||
66 | this.length = Number(opts.length) || Infinity | ||
67 | |||
68 | if (this.length !== Infinity) { | ||
69 | this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength | ||
70 | this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 | ||
71 | } | ||
72 | |||
73 | this.db = new ChunkDatabase(this.databaseName) | ||
74 | // Track databases that expired | ||
75 | this.expirationDB = new ExpirationDatabase() | ||
76 | |||
77 | this.runCleaner() | ||
78 | } | ||
79 | |||
80 | put (index: number, buf: Buffer, cb: (err?: Error) => void) { | ||
81 | const isLastChunk = (index === this.lastChunkIndex) | ||
82 | if (isLastChunk && buf.length !== this.lastChunkLength) { | ||
83 | return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) | ||
84 | } | ||
85 | if (!isLastChunk && buf.length !== this.chunkLength) { | ||
86 | return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) | ||
87 | } | ||
88 | |||
89 | // Specify we have this chunk | ||
90 | this.memoryChunks[index] = true | ||
91 | |||
92 | // Add it to the pending put | ||
93 | this.pendingPut.push({ id: index, buf, cb }) | ||
94 | // If it's already planned, return | ||
95 | if (this.putBulkTimeout) return | ||
96 | |||
97 | // Plan a future bulk insert | ||
98 | this.putBulkTimeout = setTimeout(async () => { | ||
99 | const processing = this.pendingPut | ||
100 | this.pendingPut = [] | ||
101 | this.putBulkTimeout = undefined | ||
102 | |||
103 | try { | ||
104 | await this.db.transaction('rw', this.db.chunks, () => { | ||
105 | return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) | ||
106 | }) | ||
107 | } catch (err) { | ||
108 | logger.info('Cannot bulk insert chunks. Store them in memory.', err) | ||
109 | |||
110 | processing.forEach(p => { | ||
111 | this.memoryChunks[p.id] = p.buf | ||
112 | }) | ||
113 | } finally { | ||
114 | processing.forEach(p => p.cb()) | ||
115 | } | ||
116 | }, PeertubeChunkStore.BUFFERING_PUT_MS) | ||
117 | } | ||
118 | |||
119 | get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { | ||
120 | if (typeof opts === 'function') return this.get(index, null, opts) | ||
121 | |||
122 | // IndexDB could be slow, use our memory index first | ||
123 | const memoryChunk = this.memoryChunks[index] | ||
124 | if (memoryChunk === undefined) { | ||
125 | const err = new Error('Chunk not found') as any | ||
126 | err['notFound'] = true | ||
127 | |||
128 | return process.nextTick(() => cb(err)) | ||
129 | } | ||
130 | |||
131 | // Chunk in memory | ||
132 | if (memoryChunk !== true) return cb(null, memoryChunk) | ||
133 | |||
134 | // Chunk in store | ||
135 | this.db.transaction('r', this.db.chunks, async () => { | ||
136 | const result = await this.db.chunks.get({ id: index }) | ||
137 | if (result === undefined) return cb(null, Buffer.alloc(0)) | ||
138 | |||
139 | const buf = result.buf | ||
140 | if (!opts) return this.nextTick(cb, null, buf) | ||
141 | |||
142 | const offset = opts.offset || 0 | ||
143 | const len = opts.length || (buf.length - offset) | ||
144 | return cb(null, buf.slice(offset, len + offset)) | ||
145 | }) | ||
146 | .catch(err => { | ||
147 | logger.error(err) | ||
148 | return cb(err) | ||
149 | }) | ||
150 | } | ||
151 | |||
152 | close (cb: (err?: Error) => void) { | ||
153 | return this.destroy(cb) | ||
154 | } | ||
155 | |||
156 | async destroy (cb: (err?: Error) => void) { | ||
157 | try { | ||
158 | if (this.pendingPut) { | ||
159 | clearTimeout(this.putBulkTimeout) | ||
160 | this.pendingPut = null | ||
161 | } | ||
162 | if (this.cleanerInterval) { | ||
163 | clearInterval(this.cleanerInterval) | ||
164 | this.cleanerInterval = null | ||
165 | } | ||
166 | |||
167 | if (this.db) { | ||
168 | this.db.close() | ||
169 | |||
170 | await this.dropDatabase(this.databaseName) | ||
171 | } | ||
172 | |||
173 | if (this.expirationDB) { | ||
174 | this.expirationDB.close() | ||
175 | this.expirationDB = null | ||
176 | } | ||
177 | |||
178 | return cb() | ||
179 | } catch (err) { | ||
180 | logger.error('Cannot destroy peertube chunk store.', err) | ||
181 | return cb(err) | ||
182 | } | ||
183 | } | ||
184 | |||
185 | private runCleaner () { | ||
186 | this.checkExpiration() | ||
187 | |||
188 | this.cleanerInterval = setInterval(() => { | ||
189 | this.checkExpiration() | ||
190 | }, PeertubeChunkStore.CLEANER_INTERVAL_MS) | ||
191 | } | ||
192 | |||
193 | private async checkExpiration () { | ||
194 | let databasesToDeleteInfo: { name: string }[] = [] | ||
195 | |||
196 | try { | ||
197 | await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { | ||
198 | // Update our database expiration since we are alive | ||
199 | await this.expirationDB.databases.put({ | ||
200 | name: this.databaseName, | ||
201 | expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS | ||
202 | }) | ||
203 | |||
204 | const now = new Date().getTime() | ||
205 | databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() | ||
206 | }) | ||
207 | } catch (err) { | ||
208 | logger.error('Cannot update expiration of fetch expired databases.', err) | ||
209 | } | ||
210 | |||
211 | for (const databaseToDeleteInfo of databasesToDeleteInfo) { | ||
212 | await this.dropDatabase(databaseToDeleteInfo.name) | ||
213 | } | ||
214 | } | ||
215 | |||
216 | private async dropDatabase (databaseName: string) { | ||
217 | const dbToDelete = new ChunkDatabase(databaseName) | ||
218 | logger.info(`Destroying IndexDB database ${databaseName}`) | ||
219 | |||
220 | try { | ||
221 | await dbToDelete.delete() | ||
222 | |||
223 | await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { | ||
224 | return this.expirationDB.databases.where({ name: databaseName }).delete() | ||
225 | }) | ||
226 | } catch (err) { | ||
227 | logger.error(`Cannot delete ${databaseName}.`, err) | ||
228 | } | ||
229 | } | ||
230 | |||
231 | private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { | ||
232 | process.nextTick(() => cb(err, val), undefined) | ||
233 | } | ||
234 | } | ||
diff --git a/client/src/assets/player/shared/webtorrent/video-renderer.ts b/client/src/assets/player/shared/webtorrent/video-renderer.ts deleted file mode 100644 index a85d7a838..000000000 --- a/client/src/assets/player/shared/webtorrent/video-renderer.ts +++ /dev/null | |||
@@ -1,134 +0,0 @@ | |||
1 | // Thanks: https://github.com/feross/render-media | ||
2 | |||
3 | const MediaElementWrapper = require('mediasource') | ||
4 | import { logger } from '@root-helpers/logger' | ||
5 | import { extname } from 'path' | ||
6 | const Videostream = require('videostream') | ||
7 | |||
8 | const VIDEOSTREAM_EXTS = [ | ||
9 | '.m4a', | ||
10 | '.m4v', | ||
11 | '.mp4' | ||
12 | ] | ||
13 | |||
14 | type RenderMediaOptions = { | ||
15 | controls: boolean | ||
16 | autoplay: boolean | ||
17 | } | ||
18 | |||
19 | function renderVideo ( | ||
20 | file: any, | ||
21 | elem: HTMLVideoElement, | ||
22 | opts: RenderMediaOptions, | ||
23 | callback: (err: Error, renderer: any) => void | ||
24 | ) { | ||
25 | validateFile(file) | ||
26 | |||
27 | return renderMedia(file, elem, opts, callback) | ||
28 | } | ||
29 | |||
30 | function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { | ||
31 | const extension = extname(file.name).toLowerCase() | ||
32 | let preparedElem: any | ||
33 | let currentTime = 0 | ||
34 | let renderer: any | ||
35 | |||
36 | try { | ||
37 | if (VIDEOSTREAM_EXTS.includes(extension)) { | ||
38 | renderer = useVideostream() | ||
39 | } else { | ||
40 | renderer = useMediaSource() | ||
41 | } | ||
42 | } catch (err) { | ||
43 | return callback(err) | ||
44 | } | ||
45 | |||
46 | function useVideostream () { | ||
47 | prepareElem() | ||
48 | preparedElem.addEventListener('error', function onError (err: Error) { | ||
49 | preparedElem.removeEventListener('error', onError) | ||
50 | |||
51 | return callback(err) | ||
52 | }) | ||
53 | preparedElem.addEventListener('loadstart', onLoadStart) | ||
54 | return new Videostream(file, preparedElem) | ||
55 | } | ||
56 | |||
57 | function useMediaSource (useVP9 = false) { | ||
58 | const codecs = getCodec(file.name, useVP9) | ||
59 | |||
60 | prepareElem() | ||
61 | preparedElem.addEventListener('error', function onError (err: Error) { | ||
62 | preparedElem.removeEventListener('error', onError) | ||
63 | |||
64 | // Try with vp9 before returning an error | ||
65 | if (codecs.includes('vp8')) return fallbackToMediaSource(true) | ||
66 | |||
67 | return callback(err) | ||
68 | }) | ||
69 | preparedElem.addEventListener('loadstart', onLoadStart) | ||
70 | |||
71 | const wrapper = new MediaElementWrapper(preparedElem) | ||
72 | const writable = wrapper.createWriteStream(codecs) | ||
73 | file.createReadStream().pipe(writable) | ||
74 | |||
75 | if (currentTime) preparedElem.currentTime = currentTime | ||
76 | |||
77 | return wrapper | ||
78 | } | ||
79 | |||
80 | function fallbackToMediaSource (useVP9 = false) { | ||
81 | if (useVP9 === true) logger.info('Falling back to media source with VP9 enabled.') | ||
82 | else logger.info('Falling back to media source..') | ||
83 | |||
84 | useMediaSource(useVP9) | ||
85 | } | ||
86 | |||
87 | function prepareElem () { | ||
88 | if (preparedElem === undefined) { | ||
89 | preparedElem = elem | ||
90 | |||
91 | preparedElem.addEventListener('progress', function () { | ||
92 | currentTime = elem.currentTime | ||
93 | }) | ||
94 | } | ||
95 | } | ||
96 | |||
97 | function onLoadStart () { | ||
98 | preparedElem.removeEventListener('loadstart', onLoadStart) | ||
99 | if (opts.autoplay) preparedElem.play() | ||
100 | |||
101 | callback(null, renderer) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | function validateFile (file: any) { | ||
106 | if (file == null) { | ||
107 | throw new Error('file cannot be null or undefined') | ||
108 | } | ||
109 | if (typeof file.name !== 'string') { | ||
110 | throw new Error('missing or invalid file.name property') | ||
111 | } | ||
112 | if (typeof file.createReadStream !== 'function') { | ||
113 | throw new Error('missing or invalid file.createReadStream property') | ||
114 | } | ||
115 | } | ||
116 | |||
117 | function getCodec (name: string, useVP9 = false) { | ||
118 | const ext = extname(name).toLowerCase() | ||
119 | if (ext === '.mp4') { | ||
120 | return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' | ||
121 | } | ||
122 | |||
123 | if (ext === '.webm') { | ||
124 | if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' | ||
125 | |||
126 | return 'video/webm; codecs="vp8, vorbis"' | ||
127 | } | ||
128 | |||
129 | return undefined | ||
130 | } | ||
131 | |||
132 | export { | ||
133 | renderVideo | ||
134 | } | ||
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts deleted file mode 100644 index e2e220c03..000000000 --- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts +++ /dev/null | |||
@@ -1,663 +0,0 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import * as WebTorrent from 'webtorrent' | ||
3 | import { logger } from '@root-helpers/logger' | ||
4 | import { isIOS } from '@root-helpers/web-browser' | ||
5 | import { addQueryParams, timeToInt } from '@shared/core-utils' | ||
6 | import { VideoFile } from '@shared/models' | ||
7 | import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage' | ||
8 | import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types' | ||
9 | import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common' | ||
10 | import { PeertubeChunkStore } from './peertube-chunk-store' | ||
11 | import { renderVideo } from './video-renderer' | ||
12 | |||
13 | const CacheChunkStore = require('cache-chunk-store') | ||
14 | |||
15 | type PlayOptions = { | ||
16 | forcePlay?: boolean | ||
17 | seek?: number | ||
18 | delay?: number | ||
19 | } | ||
20 | |||
21 | const Plugin = videojs.getPlugin('plugin') | ||
22 | |||
23 | class WebTorrentPlugin extends Plugin { | ||
24 | readonly videoFiles: VideoFile[] | ||
25 | |||
26 | private readonly playerElement: HTMLVideoElement | ||
27 | |||
28 | private readonly autoplay: boolean | string = false | ||
29 | private readonly startTime: number = 0 | ||
30 | private readonly savePlayerSrcFunction: videojs.Player['src'] | ||
31 | private readonly videoDuration: number | ||
32 | private readonly CONSTANTS = { | ||
33 | INFO_SCHEDULER: 1000, // Don't change this | ||
34 | AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds | ||
35 | AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it | ||
36 | AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check | ||
37 | AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds | ||
38 | BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth | ||
39 | } | ||
40 | |||
41 | private readonly buildWebSeedUrls: (file: VideoFile) => string[] | ||
42 | |||
43 | private readonly webtorrent = new WebTorrent({ | ||
44 | tracker: { | ||
45 | rtcConfig: getRtcConfig() | ||
46 | }, | ||
47 | dht: false | ||
48 | }) | ||
49 | |||
50 | private currentVideoFile: VideoFile | ||
51 | private torrent: WebTorrent.Torrent | ||
52 | |||
53 | private renderer: any | ||
54 | private fakeRenderer: any | ||
55 | private destroyingFakeRenderer = false | ||
56 | |||
57 | private autoResolution = true | ||
58 | private autoResolutionPossible = true | ||
59 | private isAutoResolutionObservation = false | ||
60 | private playerRefusedP2P = false | ||
61 | |||
62 | private requiresUserAuth: boolean | ||
63 | private videoFileToken: () => string | ||
64 | |||
65 | private torrentInfoInterval: any | ||
66 | private autoQualityInterval: any | ||
67 | private addTorrentDelay: any | ||
68 | private qualityObservationTimer: any | ||
69 | private runAutoQualitySchedulerTimer: any | ||
70 | |||
71 | private downloadSpeeds: number[] = [] | ||
72 | |||
73 | constructor (player: videojs.Player, options?: WebtorrentPluginOptions) { | ||
74 | super(player) | ||
75 | |||
76 | this.startTime = timeToInt(options.startTime) | ||
77 | |||
78 | // Custom autoplay handled by webtorrent because we lazy play the video | ||
79 | this.autoplay = options.autoplay | ||
80 | |||
81 | this.playerRefusedP2P = options.playerRefusedP2P | ||
82 | |||
83 | this.videoFiles = options.videoFiles | ||
84 | this.videoDuration = options.videoDuration | ||
85 | |||
86 | this.savePlayerSrcFunction = this.player.src | ||
87 | this.playerElement = options.playerElement | ||
88 | |||
89 | this.requiresUserAuth = options.requiresUserAuth | ||
90 | this.videoFileToken = options.videoFileToken | ||
91 | |||
92 | this.buildWebSeedUrls = options.buildWebSeedUrls | ||
93 | |||
94 | this.player.ready(() => { | ||
95 | const playerOptions = this.player.options_ | ||
96 | |||
97 | const volume = getStoredVolume() | ||
98 | if (volume !== undefined) this.player.volume(volume) | ||
99 | |||
100 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | ||
101 | if (muted !== undefined) this.player.muted(muted) | ||
102 | |||
103 | this.player.duration(options.videoDuration) | ||
104 | |||
105 | this.initializePlayer() | ||
106 | this.runTorrentInfoScheduler() | ||
107 | |||
108 | this.player.one('play', () => { | ||
109 | // Don't run immediately scheduler, wait some seconds the TCP connections are made | ||
110 | this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) | ||
111 | }) | ||
112 | }) | ||
113 | } | ||
114 | |||
115 | dispose () { | ||
116 | clearTimeout(this.addTorrentDelay) | ||
117 | clearTimeout(this.qualityObservationTimer) | ||
118 | clearTimeout(this.runAutoQualitySchedulerTimer) | ||
119 | |||
120 | clearInterval(this.torrentInfoInterval) | ||
121 | clearInterval(this.autoQualityInterval) | ||
122 | |||
123 | // Don't need to destroy renderer, video player will be destroyed | ||
124 | this.flushVideoFile(this.currentVideoFile, false) | ||
125 | |||
126 | this.destroyFakeRenderer() | ||
127 | } | ||
128 | |||
129 | getCurrentResolutionId () { | ||
130 | return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 | ||
131 | } | ||
132 | |||
133 | updateVideoFile ( | ||
134 | videoFile?: VideoFile, | ||
135 | options: { | ||
136 | forcePlay?: boolean | ||
137 | seek?: number | ||
138 | delay?: number | ||
139 | } = {}, | ||
140 | done: () => void = () => { /* empty */ } | ||
141 | ) { | ||
142 | // Automatically choose the adapted video file | ||
143 | if (!videoFile) { | ||
144 | const savedAverageBandwidth = getAverageBandwidthInStore() | ||
145 | videoFile = savedAverageBandwidth | ||
146 | ? this.getAppropriateFile(savedAverageBandwidth) | ||
147 | : this.pickAverageVideoFile() | ||
148 | } | ||
149 | |||
150 | if (!videoFile) { | ||
151 | throw Error(`Can't update video file since videoFile is undefined.`) | ||
152 | } | ||
153 | |||
154 | // Don't add the same video file once again | ||
155 | if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { | ||
156 | return | ||
157 | } | ||
158 | |||
159 | // Do not display error to user because we will have multiple fallback | ||
160 | this.player.peertube().hideFatalError(); | ||
161 | |||
162 | // Hack to "simulate" src link in video.js >= 6 | ||
163 | // Without this, we can't play the video after pausing it | ||
164 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | ||
165 | (this.player as any).src = () => true | ||
166 | const oldPlaybackRate = this.player.playbackRate() | ||
167 | |||
168 | const previousVideoFile = this.currentVideoFile | ||
169 | this.currentVideoFile = videoFile | ||
170 | |||
171 | // Don't try on iOS that does not support MediaSource | ||
172 | // Or don't use P2P if webtorrent is disabled | ||
173 | if (isIOS() || this.playerRefusedP2P) { | ||
174 | return this.fallbackToHttp(options, () => { | ||
175 | this.player.playbackRate(oldPlaybackRate) | ||
176 | return done() | ||
177 | }) | ||
178 | } | ||
179 | |||
180 | this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { | ||
181 | this.player.playbackRate(oldPlaybackRate) | ||
182 | return done() | ||
183 | }) | ||
184 | |||
185 | this.selectAppropriateResolution(true) | ||
186 | } | ||
187 | |||
188 | updateEngineResolution (resolutionId: number, delay = 0) { | ||
189 | // Remember player state | ||
190 | const currentTime = this.player.currentTime() | ||
191 | const isPaused = this.player.paused() | ||
192 | |||
193 | // Hide bigPlayButton | ||
194 | if (!isPaused) { | ||
195 | this.player.bigPlayButton.hide() | ||
196 | } | ||
197 | |||
198 | // Audio-only (resolutionId === 0) gets special treatment | ||
199 | if (resolutionId === 0) { | ||
200 | // Audio-only: show poster, do not auto-hide controls | ||
201 | this.player.addClass('vjs-playing-audio-only-content') | ||
202 | this.player.posterImage.show() | ||
203 | } else { | ||
204 | // Hide poster to have black background | ||
205 | this.player.removeClass('vjs-playing-audio-only-content') | ||
206 | this.player.posterImage.hide() | ||
207 | } | ||
208 | |||
209 | const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) | ||
210 | const options = { | ||
211 | forcePlay: false, | ||
212 | delay, | ||
213 | seek: currentTime + (delay / 1000) | ||
214 | } | ||
215 | |||
216 | this.updateVideoFile(newVideoFile, options) | ||
217 | |||
218 | this.player.trigger('engineResolutionChange') | ||
219 | } | ||
220 | |||
221 | flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { | ||
222 | if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { | ||
223 | if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() | ||
224 | |||
225 | this.webtorrent.remove(videoFile.magnetUri) | ||
226 | logger.info(`Removed ${videoFile.magnetUri}`) | ||
227 | } | ||
228 | } | ||
229 | |||
230 | disableAutoResolution () { | ||
231 | this.autoResolution = false | ||
232 | this.autoResolutionPossible = false | ||
233 | this.player.peertubeResolutions().disableAutoResolution() | ||
234 | } | ||
235 | |||
236 | isAutoResolutionPossible () { | ||
237 | return this.autoResolutionPossible | ||
238 | } | ||
239 | |||
240 | getTorrent () { | ||
241 | return this.torrent | ||
242 | } | ||
243 | |||
244 | getCurrentVideoFile () { | ||
245 | return this.currentVideoFile | ||
246 | } | ||
247 | |||
248 | changeQuality (id: number) { | ||
249 | if (id === -1) { | ||
250 | if (this.autoResolutionPossible === true) { | ||
251 | this.autoResolution = true | ||
252 | |||
253 | this.selectAppropriateResolution(false) | ||
254 | } | ||
255 | |||
256 | return | ||
257 | } | ||
258 | |||
259 | this.autoResolution = false | ||
260 | this.updateEngineResolution(id) | ||
261 | this.selectAppropriateResolution(false) | ||
262 | } | ||
263 | |||
264 | private addTorrent ( | ||
265 | magnetOrTorrentUrl: string, | ||
266 | previousVideoFile: VideoFile, | ||
267 | options: PlayOptions, | ||
268 | done: (err?: Error) => void | ||
269 | ) { | ||
270 | if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done) | ||
271 | |||
272 | logger.info(`Adding ${magnetOrTorrentUrl}.`) | ||
273 | |||
274 | const oldTorrent = this.torrent | ||
275 | const torrentOptions = { | ||
276 | // Don't use arrow function: it breaks webtorrent (that uses `new` keyword) | ||
277 | store: function (chunkLength: number, storeOpts: any) { | ||
278 | return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { | ||
279 | max: 100 | ||
280 | }) | ||
281 | }, | ||
282 | urlList: this.buildWebSeedUrls(this.currentVideoFile) | ||
283 | } | ||
284 | |||
285 | this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { | ||
286 | logger.info(`Added ${magnetOrTorrentUrl}.`) | ||
287 | |||
288 | if (oldTorrent) { | ||
289 | // Pause the old torrent | ||
290 | this.stopTorrent(oldTorrent) | ||
291 | |||
292 | // We use a fake renderer so we download correct pieces of the next file | ||
293 | if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay) | ||
294 | } | ||
295 | |||
296 | // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) | ||
297 | this.addTorrentDelay = setTimeout(() => { | ||
298 | // We don't need the fake renderer anymore | ||
299 | this.destroyFakeRenderer() | ||
300 | |||
301 | const paused = this.player.paused() | ||
302 | |||
303 | this.flushVideoFile(previousVideoFile) | ||
304 | |||
305 | // Update progress bar (just for the UI), do not wait rendering | ||
306 | if (options.seek) this.player.currentTime(options.seek) | ||
307 | |||
308 | const renderVideoOptions = { autoplay: false, controls: true } | ||
309 | renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => { | ||
310 | this.renderer = renderer | ||
311 | |||
312 | if (err) return this.fallbackToHttp(options, done) | ||
313 | |||
314 | return this.tryToPlay(err => { | ||
315 | if (err) return done(err) | ||
316 | |||
317 | if (options.seek) this.seek(options.seek) | ||
318 | if (options.forcePlay === false && paused === true) this.player.pause() | ||
319 | |||
320 | return done() | ||
321 | }) | ||
322 | }) | ||
323 | }, options.delay || 0) | ||
324 | }) | ||
325 | |||
326 | this.torrent.on('error', (err: any) => logger.error(err)) | ||
327 | |||
328 | this.torrent.on('warning', (err: any) => { | ||
329 | // We don't support HTTP tracker but we don't care -> we use the web socket tracker | ||
330 | if (err.message.indexOf('Unsupported tracker protocol') !== -1) return | ||
331 | |||
332 | // Users don't care about issues with WebRTC, but developers do so log it in the console | ||
333 | if (err.message.indexOf('Ice connection failed') !== -1) { | ||
334 | logger.info(err) | ||
335 | return | ||
336 | } | ||
337 | |||
338 | // Magnet hash is not up to date with the torrent file, add directly the torrent file | ||
339 | if (err.message.indexOf('incorrect info hash') !== -1) { | ||
340 | logger.error('Incorrect info hash detected, falling back to torrent file.') | ||
341 | const newOptions = { forcePlay: true, seek: options.seek } | ||
342 | return this.addTorrent((this.torrent as any)['xs'], previousVideoFile, newOptions, done) | ||
343 | } | ||
344 | |||
345 | // Remote instance is down | ||
346 | if (err.message.indexOf('from xs param') !== -1) { | ||
347 | this.handleError(err) | ||
348 | } | ||
349 | |||
350 | logger.warn(err) | ||
351 | }) | ||
352 | } | ||
353 | |||
354 | private tryToPlay (done?: (err?: Error) => void) { | ||
355 | if (!done) done = function () { /* empty */ } | ||
356 | |||
357 | const playPromise = this.player.play() | ||
358 | if (playPromise !== undefined) { | ||
359 | return playPromise.then(() => done()) | ||
360 | .catch((err: Error) => { | ||
361 | if (err.message.includes('The play() request was interrupted by a call to pause()')) { | ||
362 | return | ||
363 | } | ||
364 | |||
365 | logger.warn(err) | ||
366 | this.player.pause() | ||
367 | this.player.posterImage.show() | ||
368 | this.player.removeClass('vjs-has-autoplay') | ||
369 | this.player.removeClass('vjs-has-big-play-button-clicked') | ||
370 | this.player.removeClass('vjs-playing-audio-only-content') | ||
371 | |||
372 | return done() | ||
373 | }) | ||
374 | } | ||
375 | |||
376 | return done() | ||
377 | } | ||
378 | |||
379 | private seek (time: number) { | ||
380 | this.player.currentTime(time) | ||
381 | this.player.handleTechSeeked_() | ||
382 | } | ||
383 | |||
384 | private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { | ||
385 | if (this.videoFiles === undefined) return undefined | ||
386 | if (this.videoFiles.length === 1) return this.videoFiles[0] | ||
387 | |||
388 | const files = this.videoFiles.filter(f => f.resolution.id !== 0) | ||
389 | if (files.length === 0) return undefined | ||
390 | |||
391 | // Don't change the torrent if the player ended | ||
392 | if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile | ||
393 | |||
394 | if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() | ||
395 | |||
396 | // Limit resolution according to player height | ||
397 | const playerHeight = this.playerElement.offsetHeight | ||
398 | |||
399 | // We take the first resolution just above the player height | ||
400 | // Example: player height is 530px, we want the 720p file instead of 480p | ||
401 | let maxResolution = files[0].resolution.id | ||
402 | for (let i = files.length - 1; i >= 0; i--) { | ||
403 | const resolutionId = files[i].resolution.id | ||
404 | if (resolutionId !== 0 && resolutionId >= playerHeight) { | ||
405 | maxResolution = resolutionId | ||
406 | break | ||
407 | } | ||
408 | } | ||
409 | |||
410 | // Filter videos we can play according to our screen resolution and bandwidth | ||
411 | const filteredFiles = files.filter(f => f.resolution.id <= maxResolution) | ||
412 | .filter(f => { | ||
413 | const fileBitrate = (f.size / this.videoDuration) | ||
414 | let threshold = fileBitrate | ||
415 | |||
416 | // If this is for a higher resolution or an initial load: add a margin | ||
417 | if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { | ||
418 | threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) | ||
419 | } | ||
420 | |||
421 | return averageDownloadSpeed > threshold | ||
422 | }) | ||
423 | |||
424 | // If the download speed is too bad, return the lowest resolution we have | ||
425 | if (filteredFiles.length === 0) return videoFileMinByResolution(files) | ||
426 | |||
427 | return videoFileMaxByResolution(filteredFiles) | ||
428 | } | ||
429 | |||
430 | private getAndSaveActualDownloadSpeed () { | ||
431 | const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) | ||
432 | const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) | ||
433 | if (lastDownloadSpeeds.length === 0) return -1 | ||
434 | |||
435 | const sum = lastDownloadSpeeds.reduce((a, b) => a + b) | ||
436 | const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) | ||
437 | |||
438 | // Save the average bandwidth for future use | ||
439 | saveAverageBandwidth(averageBandwidth) | ||
440 | |||
441 | return averageBandwidth | ||
442 | } | ||
443 | |||
444 | private initializePlayer () { | ||
445 | this.buildQualities() | ||
446 | |||
447 | if (this.videoFiles.length === 0) { | ||
448 | this.player.addClass('disabled') | ||
449 | return | ||
450 | } | ||
451 | |||
452 | if (this.autoplay !== false) { | ||
453 | this.player.posterImage.hide() | ||
454 | |||
455 | return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) | ||
456 | } | ||
457 | |||
458 | // Proxy first play | ||
459 | const oldPlay = this.player.play.bind(this.player); | ||
460 | (this.player as any).play = () => { | ||
461 | this.player.addClass('vjs-has-big-play-button-clicked') | ||
462 | this.player.play = oldPlay | ||
463 | |||
464 | this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) | ||
465 | } | ||
466 | } | ||
467 | |||
468 | private runAutoQualityScheduler () { | ||
469 | this.autoQualityInterval = setInterval(() => { | ||
470 | |||
471 | // Not initialized or in HTTP fallback | ||
472 | if (this.torrent === undefined || this.torrent === null) return | ||
473 | if (this.autoResolution === false) return | ||
474 | if (this.isAutoResolutionObservation === true) return | ||
475 | |||
476 | const file = this.getAppropriateFile() | ||
477 | let changeResolution = false | ||
478 | let changeResolutionDelay = 0 | ||
479 | |||
480 | // Lower resolution | ||
481 | if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { | ||
482 | logger.info(`Downgrading automatically the resolution to: ${file.resolution.label}`) | ||
483 | changeResolution = true | ||
484 | } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution | ||
485 | logger.info(`Upgrading automatically the resolution to: ${file.resolution.label}`) | ||
486 | changeResolution = true | ||
487 | changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY | ||
488 | } | ||
489 | |||
490 | if (changeResolution === true) { | ||
491 | this.updateEngineResolution(file.resolution.id, changeResolutionDelay) | ||
492 | |||
493 | // Wait some seconds in observation of our new resolution | ||
494 | this.isAutoResolutionObservation = true | ||
495 | |||
496 | this.qualityObservationTimer = setTimeout(() => { | ||
497 | this.isAutoResolutionObservation = false | ||
498 | }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) | ||
499 | } | ||
500 | }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) | ||
501 | } | ||
502 | |||
503 | private isPlayerWaiting () { | ||
504 | return this.player?.hasClass('vjs-waiting') | ||
505 | } | ||
506 | |||
507 | private runTorrentInfoScheduler () { | ||
508 | this.torrentInfoInterval = setInterval(() => { | ||
509 | // Not initialized yet | ||
510 | if (this.torrent === undefined) return | ||
511 | |||
512 | // Http fallback | ||
513 | if (this.torrent === null) return this.player.trigger('p2pInfo', false) | ||
514 | |||
515 | // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too | ||
516 | if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) | ||
517 | |||
518 | return this.player.trigger('p2pInfo', { | ||
519 | source: 'webtorrent', | ||
520 | http: { | ||
521 | downloadSpeed: 0, | ||
522 | downloaded: 0 | ||
523 | }, | ||
524 | p2p: { | ||
525 | downloadSpeed: this.torrent.downloadSpeed, | ||
526 | numPeers: this.torrent.numPeers, | ||
527 | uploadSpeed: this.torrent.uploadSpeed, | ||
528 | downloaded: this.torrent.downloaded, | ||
529 | uploaded: this.torrent.uploaded | ||
530 | }, | ||
531 | bandwidthEstimate: this.webtorrent.downloadSpeed | ||
532 | } as PlayerNetworkInfo) | ||
533 | }, this.CONSTANTS.INFO_SCHEDULER) | ||
534 | } | ||
535 | |||
536 | private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) { | ||
537 | const paused = this.player.paused() | ||
538 | |||
539 | this.disableAutoResolution() | ||
540 | |||
541 | this.flushVideoFile(this.currentVideoFile, true) | ||
542 | this.torrent = null | ||
543 | |||
544 | // Enable error display now this is our last fallback | ||
545 | this.player.one('error', () => this.player.peertube().displayFatalError()) | ||
546 | |||
547 | let httpUrl = this.currentVideoFile.fileUrl | ||
548 | |||
549 | if (this.videoFileToken) { | ||
550 | httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) | ||
551 | } | ||
552 | |||
553 | this.player.src = this.savePlayerSrcFunction | ||
554 | this.player.src(httpUrl) | ||
555 | |||
556 | this.selectAppropriateResolution(true) | ||
557 | |||
558 | // We changed the source, so reinit captions | ||
559 | this.player.trigger('sourcechange') | ||
560 | |||
561 | return this.tryToPlay(err => { | ||
562 | if (err && done) return done(err) | ||
563 | |||
564 | if (options.seek) this.seek(options.seek) | ||
565 | if (options.forcePlay === false && paused === true) this.player.pause() | ||
566 | |||
567 | if (done) return done() | ||
568 | }) | ||
569 | } | ||
570 | |||
571 | private handleError (err: Error | string) { | ||
572 | return this.player.trigger('customError', { err }) | ||
573 | } | ||
574 | |||
575 | private pickAverageVideoFile () { | ||
576 | if (this.videoFiles.length === 1) return this.videoFiles[0] | ||
577 | |||
578 | const files = this.videoFiles.filter(f => f.resolution.id !== 0) | ||
579 | return files[Math.floor(files.length / 2)] | ||
580 | } | ||
581 | |||
582 | private stopTorrent (torrent: WebTorrent.Torrent) { | ||
583 | torrent.pause() | ||
584 | // Pause does not remove actual peers (in particular the webseed peer) | ||
585 | torrent.removePeer((torrent as any)['ws']) | ||
586 | } | ||
587 | |||
588 | private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { | ||
589 | this.destroyingFakeRenderer = false | ||
590 | |||
591 | const fakeVideoElem = document.createElement('video') | ||
592 | renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { | ||
593 | this.fakeRenderer = renderer | ||
594 | |||
595 | // The renderer returns an error when we destroy it, so skip them | ||
596 | if (this.destroyingFakeRenderer === false && err) { | ||
597 | logger.error('Cannot render new torrent in fake video element.', err) | ||
598 | } | ||
599 | |||
600 | // Load the future file at the correct time (in delay MS - 2 seconds) | ||
601 | fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) | ||
602 | }) | ||
603 | } | ||
604 | |||
605 | private destroyFakeRenderer () { | ||
606 | if (this.fakeRenderer) { | ||
607 | this.destroyingFakeRenderer = true | ||
608 | |||
609 | if (this.fakeRenderer.destroy) { | ||
610 | try { | ||
611 | this.fakeRenderer.destroy() | ||
612 | } catch (err) { | ||
613 | logger.info('Cannot destroy correctly fake renderer.', err) | ||
614 | } | ||
615 | } | ||
616 | this.fakeRenderer = undefined | ||
617 | } | ||
618 | } | ||
619 | |||
620 | private buildQualities () { | ||
621 | const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({ | ||
622 | id: file.resolution.id, | ||
623 | label: this.buildQualityLabel(file), | ||
624 | height: file.resolution.id, | ||
625 | selected: false, | ||
626 | selectCallback: () => this.changeQuality(file.resolution.id) | ||
627 | })) | ||
628 | |||
629 | resolutions.push({ | ||
630 | id: -1, | ||
631 | label: this.player.localize('Auto'), | ||
632 | selected: true, | ||
633 | selectCallback: () => this.changeQuality(-1) | ||
634 | }) | ||
635 | |||
636 | this.player.peertubeResolutions().add(resolutions) | ||
637 | } | ||
638 | |||
639 | private buildQualityLabel (file: VideoFile) { | ||
640 | let label = file.resolution.label | ||
641 | |||
642 | if (file.fps && file.fps >= 50) { | ||
643 | label += file.fps | ||
644 | } | ||
645 | |||
646 | return label | ||
647 | } | ||
648 | |||
649 | private selectAppropriateResolution (byEngine: boolean) { | ||
650 | const resolution = this.autoResolution | ||
651 | ? -1 | ||
652 | : this.getCurrentResolutionId() | ||
653 | |||
654 | const autoResolutionChosen = this.autoResolution | ||
655 | ? this.getCurrentResolutionId() | ||
656 | : undefined | ||
657 | |||
658 | this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine }) | ||
659 | } | ||
660 | } | ||
661 | |||
662 | videojs.registerPlugin('webtorrent', WebTorrentPlugin) | ||
663 | export { WebTorrentPlugin } | ||
diff --git a/client/src/assets/player/types/index.ts b/client/src/assets/player/types/index.ts index b73e0b3cb..4bf49f65c 100644 --- a/client/src/assets/player/types/index.ts +++ b/client/src/assets/player/types/index.ts | |||
@@ -1,2 +1,2 @@ | |||
1 | export * from './manager-options' | 1 | export * from './peertube-player-options' |
2 | export * from './peertube-videojs-typings' | 2 | export * from './peertube-videojs-typings' |
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/peertube-player-options.ts index a73341b4c..e1b8c7fab 100644 --- a/client/src/assets/player/types/manager-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts | |||
@@ -1,101 +1,117 @@ | |||
1 | import { PluginsManager } from '@root-helpers/plugins-manager' | 1 | import { PluginsManager } from '@root-helpers/plugins-manager' |
2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' | 2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' |
3 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | ||
3 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' | 4 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' |
4 | 5 | ||
5 | export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | 6 | export type PlayerMode = 'web-video' | 'p2p-media-loader' |
6 | 7 | ||
7 | export type WebtorrentOptions = { | 8 | export type PeerTubePlayerContructorOptions = { |
8 | videoFiles: VideoFile[] | 9 | playerElement: () => HTMLVideoElement |
9 | } | ||
10 | 10 | ||
11 | export type P2PMediaLoaderOptions = { | 11 | controls: boolean |
12 | playlistUrl: string | 12 | controlBar: boolean |
13 | segmentsSha256Url: string | ||
14 | trackerAnnounce: string[] | ||
15 | redundancyBaseUrls: string[] | ||
16 | videoFiles: VideoFile[] | ||
17 | } | ||
18 | 13 | ||
19 | export interface CustomizationOptions { | 14 | muted: boolean |
20 | startTime: number | string | 15 | loop: boolean |
21 | stopTime: number | string | ||
22 | 16 | ||
23 | controls?: boolean | 17 | peertubeLink: () => boolean |
24 | controlBar?: boolean | ||
25 | 18 | ||
26 | muted?: boolean | 19 | playbackRate?: number | string |
27 | loop?: boolean | ||
28 | subtitle?: string | ||
29 | resume?: string | ||
30 | 20 | ||
31 | peertubeLink: boolean | 21 | enableHotkeys: boolean |
22 | inactivityTimeout: number | ||
32 | 23 | ||
33 | playbackRate?: number | string | 24 | videoViewIntervalMs: number |
34 | } | ||
35 | 25 | ||
36 | export interface CommonOptions extends CustomizationOptions { | 26 | instanceName: string |
37 | playerElement: HTMLVideoElement | ||
38 | onPlayerElementChange: (element: HTMLVideoElement) => void | ||
39 | 27 | ||
40 | autoplay: boolean | 28 | theaterButton: boolean |
41 | forceAutoplay: boolean | ||
42 | 29 | ||
43 | p2pEnabled: boolean | 30 | authorizationHeader: () => string |
44 | 31 | ||
45 | nextVideo?: () => void | 32 | metricsUrl: string |
46 | hasNextVideo?: () => boolean | 33 | serverUrl: string |
47 | 34 | ||
48 | previousVideo?: () => void | 35 | errorNotifier: (message: string) => void |
49 | hasPreviousVideo?: () => boolean | ||
50 | 36 | ||
51 | playlist?: PlaylistPluginOptions | 37 | // Current web browser language |
38 | language: string | ||
52 | 39 | ||
53 | videoDuration: number | 40 | pluginsManager: PluginsManager |
54 | enableHotkeys: boolean | 41 | } |
55 | inactivityTimeout: number | ||
56 | poster: string | ||
57 | 42 | ||
58 | videoViewIntervalMs: number | 43 | export type PeerTubePlayerLoadOptions = { |
44 | mode: PlayerMode | ||
59 | 45 | ||
60 | instanceName: string | 46 | startTime?: number | string |
47 | stopTime?: number | string | ||
61 | 48 | ||
62 | theaterButton: boolean | 49 | autoplay: boolean |
63 | captions: boolean | 50 | forceAutoplay: boolean |
64 | 51 | ||
52 | poster: string | ||
53 | subtitle?: string | ||
65 | videoViewUrl: string | 54 | videoViewUrl: string |
66 | authorizationHeader?: () => string | ||
67 | |||
68 | metricsUrl: string | ||
69 | 55 | ||
70 | embedUrl: string | 56 | embedUrl: string |
71 | embedTitle: string | 57 | embedTitle: string |
72 | 58 | ||
73 | isLive: boolean | 59 | isLive: boolean |
60 | |||
74 | liveOptions?: { | 61 | liveOptions?: { |
75 | latencyMode: LiveVideoLatencyMode | 62 | latencyMode: LiveVideoLatencyMode |
76 | } | 63 | } |
77 | 64 | ||
78 | language?: string | ||
79 | |||
80 | videoCaptions: VideoJSCaption[] | 65 | videoCaptions: VideoJSCaption[] |
81 | storyboard: VideoJSStoryboard | 66 | storyboard: VideoJSStoryboard |
82 | 67 | ||
83 | videoUUID: string | 68 | videoUUID: string |
84 | videoShortUUID: string | 69 | videoShortUUID: string |
85 | 70 | ||
86 | serverUrl: string | 71 | duration: number |
72 | |||
87 | requiresUserAuth: boolean | 73 | requiresUserAuth: boolean |
88 | videoFileToken: () => string | 74 | videoFileToken: () => string |
89 | requiresPassword: boolean | 75 | requiresPassword: boolean |
90 | videoPassword: () => string | 76 | videoPassword: () => string |
91 | 77 | ||
92 | errorNotifier: (message: string) => void | 78 | nextVideo: { |
79 | enabled: boolean | ||
80 | getVideoTitle: () => string | ||
81 | handler?: () => void | ||
82 | displayControlBarButton: boolean | ||
83 | } | ||
84 | |||
85 | previousVideo: { | ||
86 | enabled: boolean | ||
87 | handler?: () => void | ||
88 | displayControlBarButton: boolean | ||
89 | } | ||
90 | |||
91 | upnext?: { | ||
92 | isEnabled: () => boolean | ||
93 | isSuspended: (player: videojs.VideoJsPlayer) => boolean | ||
94 | timeout: number | ||
95 | } | ||
96 | |||
97 | dock?: PeerTubeDockPluginOptions | ||
98 | |||
99 | playlist?: PlaylistPluginOptions | ||
100 | |||
101 | p2pEnabled: boolean | ||
102 | |||
103 | hls?: HLSOptions | ||
104 | webVideo?: WebVideoOptions | ||
93 | } | 105 | } |
94 | 106 | ||
95 | export type PeertubePlayerManagerOptions = { | 107 | export type WebVideoOptions = { |
96 | common: CommonOptions | 108 | videoFiles: VideoFile[] |
97 | webtorrent: WebtorrentOptions | 109 | } |
98 | p2pMediaLoader?: P2PMediaLoaderOptions | ||
99 | 110 | ||
100 | pluginsManager: PluginsManager | 111 | export type HLSOptions = { |
112 | playlistUrl: string | ||
113 | segmentsSha256Url: string | ||
114 | trackerAnnounce: string[] | ||
115 | redundancyBaseUrls: string[] | ||
116 | videoFiles: VideoFile[] | ||
101 | } | 117 | } |
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 30d2b287f..f10fc03a8 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts | |||
@@ -2,8 +2,11 @@ import { HlsConfig, Level } from 'hls.js' | |||
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' | 3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' |
4 | import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' | 4 | import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' |
5 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | 5 | import { BezelsPlugin } from '../shared/bezels/bezels-plugin' |
6 | import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin' | 6 | import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' |
7 | import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | ||
8 | import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin' | ||
9 | import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin' | ||
7 | import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' | 10 | import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' |
8 | import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' | 11 | import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' |
9 | import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' | 12 | import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' |
@@ -12,9 +15,10 @@ import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' | |||
12 | import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' | 15 | import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' |
13 | import { StatsCardOptions } from '../shared/stats/stats-card' | 16 | import { StatsCardOptions } from '../shared/stats/stats-card' |
14 | import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' | 17 | import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' |
15 | import { EndCardOptions } from '../shared/upnext/end-card' | 18 | import { UpNextPlugin } from '../shared/upnext/upnext-plugin' |
16 | import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' | 19 | import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' |
17 | import { PlayerMode } from './manager-options' | 20 | import { PlayerMode } from './peertube-player-options' |
21 | import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' | ||
18 | 22 | ||
19 | declare module 'video.js' { | 23 | declare module 'video.js' { |
20 | 24 | ||
@@ -31,35 +35,36 @@ declare module 'video.js' { | |||
31 | 35 | ||
32 | handleTechSeeked_ (): void | 36 | handleTechSeeked_ (): void |
33 | 37 | ||
38 | textTracks (): TextTrackList & { | ||
39 | tracks_: (TextTrack & { id: string, label: string, src: string })[] | ||
40 | } | ||
41 | |||
34 | // Plugins | 42 | // Plugins |
35 | 43 | ||
36 | peertube (): PeerTubePlugin | 44 | peertube (): PeerTubePlugin |
37 | 45 | ||
38 | webtorrent (): WebTorrentPlugin | 46 | webVideo (options?: any): WebVideoPlugin |
39 | 47 | ||
40 | p2pMediaLoader (): P2pMediaLoaderPlugin | 48 | p2pMediaLoader (options?: any): P2pMediaLoaderPlugin |
49 | hlsjs (options?: any): any | ||
41 | 50 | ||
42 | peertubeResolutions (): PeerTubeResolutionsPlugin | 51 | peertubeResolutions (): PeerTubeResolutionsPlugin |
43 | 52 | ||
44 | contextmenuUI (options: any): any | 53 | contextmenuUI (options?: any): any |
45 | 54 | ||
46 | bezels (): void | 55 | bezels (): BezelsPlugin |
47 | peertubeMobile (): void | 56 | peertubeMobile (): PeerTubeMobilePlugin |
48 | peerTubeHotkeysPlugin (options?: HotkeysOptions): void | 57 | peerTubeHotkeysPlugin (options?: HotkeysOptions): PeerTubeHotkeysPlugin |
49 | 58 | ||
50 | stats (options?: StatsCardOptions): StatsForNerdsPlugin | 59 | stats (options?: StatsCardOptions): StatsForNerdsPlugin |
51 | 60 | ||
52 | storyboard (options: StoryboardOptions): void | 61 | storyboard (options?: StoryboardOptions): StoryboardPlugin |
53 | |||
54 | textTracks (): TextTrackList & { | ||
55 | tracks_: (TextTrack & { id: string, label: string, src: string })[] | ||
56 | } | ||
57 | 62 | ||
58 | peertubeDock (options: PeerTubeDockPluginOptions): void | 63 | peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin |
59 | 64 | ||
60 | upnext (options: Partial<EndCardOptions>): void | 65 | upnext (options?: UpNextPluginOptions): UpNextPlugin |
61 | 66 | ||
62 | playlist (): PlaylistPlugin | 67 | playlist (options?: PlaylistPluginOptions): PlaylistPlugin |
63 | } | 68 | } |
64 | } | 69 | } |
65 | 70 | ||
@@ -99,32 +104,28 @@ type VideoJSStoryboard = { | |||
99 | } | 104 | } |
100 | 105 | ||
101 | type PeerTubePluginOptions = { | 106 | type PeerTubePluginOptions = { |
102 | mode: PlayerMode | 107 | hasAutoplay: () => videojs.Autoplay |
103 | 108 | ||
104 | autoplay: videojs.Autoplay | 109 | videoViewUrl: () => string |
105 | videoDuration: number | 110 | videoViewIntervalMs: number |
106 | 111 | ||
107 | videoViewUrl: string | ||
108 | authorizationHeader?: () => string | 112 | authorizationHeader?: () => string |
109 | 113 | ||
110 | subtitle?: string | 114 | videoDuration: () => number |
111 | 115 | ||
112 | videoCaptions: VideoJSCaption[] | 116 | startTime: () => number | string |
113 | 117 | stopTime: () => number | string | |
114 | startTime: number | string | ||
115 | stopTime: number | string | ||
116 | 118 | ||
117 | isLive: boolean | 119 | videoCaptions: () => VideoJSCaption[] |
118 | 120 | isLive: () => boolean | |
119 | videoUUID: string | 121 | videoUUID: () => string |
120 | 122 | subtitle: () => string | |
121 | videoViewIntervalMs: number | ||
122 | } | 123 | } |
123 | 124 | ||
124 | type MetricsPluginOptions = { | 125 | type MetricsPluginOptions = { |
125 | mode: PlayerMode | 126 | mode: () => PlayerMode |
126 | metricsUrl: string | 127 | metricsUrl: () => string |
127 | videoUUID: string | 128 | videoUUID: () => string |
128 | } | 129 | } |
129 | 130 | ||
130 | type StoryboardOptions = { | 131 | type StoryboardOptions = { |
@@ -144,37 +145,36 @@ type PlaylistPluginOptions = { | |||
144 | onItemClicked: (element: VideoPlaylistElement) => void | 145 | onItemClicked: (element: VideoPlaylistElement) => void |
145 | } | 146 | } |
146 | 147 | ||
148 | type UpNextPluginOptions = { | ||
149 | timeout: number | ||
150 | |||
151 | next: () => void | ||
152 | getTitle: () => string | ||
153 | isDisplayed: () => boolean | ||
154 | isSuspended: () => boolean | ||
155 | } | ||
156 | |||
147 | type NextPreviousVideoButtonOptions = { | 157 | type NextPreviousVideoButtonOptions = { |
148 | type: 'next' | 'previous' | 158 | type: 'next' | 'previous' |
149 | handler: () => void | 159 | handler?: () => void |
160 | isDisplayed: () => boolean | ||
150 | isDisabled: () => boolean | 161 | isDisabled: () => boolean |
151 | } | 162 | } |
152 | 163 | ||
153 | type PeerTubeLinkButtonOptions = { | 164 | type PeerTubeLinkButtonOptions = { |
154 | shortUUID: string | 165 | isDisplayed: () => boolean |
166 | shortUUID: () => string | ||
155 | instanceName: string | 167 | instanceName: string |
156 | } | 168 | } |
157 | 169 | ||
158 | type PeerTubeP2PInfoButtonOptions = { | 170 | type TheaterButtonOptions = { |
159 | p2pEnabled: boolean | 171 | isDisplayed: () => boolean |
160 | } | 172 | } |
161 | 173 | ||
162 | type WebtorrentPluginOptions = { | 174 | type WebVideoPluginOptions = { |
163 | playerElement: HTMLVideoElement | ||
164 | |||
165 | autoplay: videojs.Autoplay | ||
166 | videoDuration: number | ||
167 | |||
168 | videoFiles: VideoFile[] | 175 | videoFiles: VideoFile[] |
169 | |||
170 | startTime: number | string | 176 | startTime: number | string |
171 | |||
172 | playerRefusedP2P: boolean | ||
173 | |||
174 | requiresUserAuth: boolean | ||
175 | videoFileToken: () => string | 177 | videoFileToken: () => string |
176 | |||
177 | buildWebSeedUrls: (file: VideoFile) => string[] | ||
178 | } | 178 | } |
179 | 179 | ||
180 | type P2PMediaLoaderPluginOptions = { | 180 | type P2PMediaLoaderPluginOptions = { |
@@ -182,9 +182,8 @@ type P2PMediaLoaderPluginOptions = { | |||
182 | type: string | 182 | type: string |
183 | src: string | 183 | src: string |
184 | 184 | ||
185 | startTime: number | string | ||
186 | |||
187 | loader: P2PMediaLoader | 185 | loader: P2PMediaLoader |
186 | segmentValidator: SegmentValidator | ||
188 | 187 | ||
189 | requiresUserAuth: boolean | 188 | requiresUserAuth: boolean |
190 | videoFileToken: () => string | 189 | videoFileToken: () => string |
@@ -192,6 +191,8 @@ type P2PMediaLoaderPluginOptions = { | |||
192 | 191 | ||
193 | export type P2PMediaLoader = { | 192 | export type P2PMediaLoader = { |
194 | getEngine(): Engine | 193 | getEngine(): Engine |
194 | |||
195 | destroy: () => void | ||
195 | } | 196 | } |
196 | 197 | ||
197 | type VideoJSPluginOptions = { | 198 | type VideoJSPluginOptions = { |
@@ -200,7 +201,7 @@ type VideoJSPluginOptions = { | |||
200 | peertube: PeerTubePluginOptions | 201 | peertube: PeerTubePluginOptions |
201 | metrics: MetricsPluginOptions | 202 | metrics: MetricsPluginOptions |
202 | 203 | ||
203 | webtorrent?: WebtorrentPluginOptions | 204 | webVideo?: WebVideoPluginOptions |
204 | 205 | ||
205 | p2pMediaLoader?: P2PMediaLoaderPluginOptions | 206 | p2pMediaLoader?: P2PMediaLoaderPluginOptions |
206 | } | 207 | } |
@@ -227,14 +228,14 @@ type AutoResolutionUpdateData = { | |||
227 | } | 228 | } |
228 | 229 | ||
229 | type PlayerNetworkInfo = { | 230 | type PlayerNetworkInfo = { |
230 | source: 'webtorrent' | 'p2p-media-loader' | 231 | source: 'web-video' | 'p2p-media-loader' |
231 | 232 | ||
232 | http: { | 233 | http: { |
233 | downloadSpeed: number | 234 | downloadSpeed?: number |
234 | downloaded: number | 235 | downloaded: number |
235 | } | 236 | } |
236 | 237 | ||
237 | p2p: { | 238 | p2p?: { |
238 | downloadSpeed: number | 239 | downloadSpeed: number |
239 | uploadSpeed: number | 240 | uploadSpeed: number |
240 | downloaded: number | 241 | downloaded: number |
@@ -243,7 +244,7 @@ type PlayerNetworkInfo = { | |||
243 | } | 244 | } |
244 | 245 | ||
245 | // In bytes | 246 | // In bytes |
246 | bandwidthEstimate: number | 247 | bandwidthEstimate?: number |
247 | } | 248 | } |
248 | 249 | ||
249 | type PlaylistItemOptions = { | 250 | type PlaylistItemOptions = { |
@@ -254,6 +255,7 @@ type PlaylistItemOptions = { | |||
254 | 255 | ||
255 | export { | 256 | export { |
256 | PlayerNetworkInfo, | 257 | PlayerNetworkInfo, |
258 | TheaterButtonOptions, | ||
257 | VideoJSStoryboard, | 259 | VideoJSStoryboard, |
258 | PlaylistItemOptions, | 260 | PlaylistItemOptions, |
259 | NextPreviousVideoButtonOptions, | 261 | NextPreviousVideoButtonOptions, |
@@ -263,12 +265,12 @@ export { | |||
263 | MetricsPluginOptions, | 265 | MetricsPluginOptions, |
264 | VideoJSCaption, | 266 | VideoJSCaption, |
265 | PeerTubePluginOptions, | 267 | PeerTubePluginOptions, |
266 | WebtorrentPluginOptions, | 268 | WebVideoPluginOptions, |
267 | P2PMediaLoaderPluginOptions, | 269 | P2PMediaLoaderPluginOptions, |
268 | PeerTubeResolution, | 270 | PeerTubeResolution, |
269 | VideoJSPluginOptions, | 271 | VideoJSPluginOptions, |
272 | UpNextPluginOptions, | ||
270 | LoadedQualityData, | 273 | LoadedQualityData, |
271 | StoryboardOptions, | 274 | StoryboardOptions, |
272 | PeerTubeLinkButtonOptions, | 275 | PeerTubeLinkButtonOptions |
273 | PeerTubeP2PInfoButtonOptions | ||
274 | } | 276 | } |
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss index 02d5fa169..09a75e2fd 100644 --- a/client/src/sass/player/control-bar.scss +++ b/client/src/sass/player/control-bar.scss | |||
@@ -3,20 +3,6 @@ | |||
3 | @use '_mixins' as *; | 3 | @use '_mixins' as *; |
4 | @use './_player-variables' as *; | 4 | @use './_player-variables' as *; |
5 | 5 | ||
6 | // Like the time tooltip | ||
7 | .video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder { | ||
8 | display: none; | ||
9 | } | ||
10 | |||
11 | .video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder, | ||
12 | .video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder { | ||
13 | display: block; | ||
14 | |||
15 | // Ensure that we maintain a font-size of ~10px. | ||
16 | font-size: 0.6em; | ||
17 | visibility: visible; | ||
18 | } | ||
19 | |||
20 | .video-js.vjs-peertube-skin .vjs-control-bar { | 6 | .video-js.vjs-peertube-skin .vjs-control-bar { |
21 | z-index: 100; | 7 | z-index: 100; |
22 | 8 | ||
@@ -26,11 +12,8 @@ | |||
26 | text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); | 12 | text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); |
27 | transition: visibility 0.3s, opacity 0.3s !important; | 13 | transition: visibility 0.3s, opacity 0.3s !important; |
28 | 14 | ||
29 | &.control-bar-hidden { | 15 | > button:not(.vjs-hidden):first-child, |
30 | display: none !important; | 16 | > button.vjs-hidden + button:not(.vjs-hidden) { |
31 | } | ||
32 | |||
33 | > button:first-child { | ||
34 | @include margin-left($first-control-bar-element-margin-left); | 17 | @include margin-left($first-control-bar-element-margin-left); |
35 | } | 18 | } |
36 | 19 | ||
@@ -167,7 +150,7 @@ | |||
167 | } | 150 | } |
168 | } | 151 | } |
169 | 152 | ||
170 | .vjs-live-control { | 153 | .vjs-pt-live-control { |
171 | padding: 5px 7px; | 154 | padding: 5px 7px; |
172 | border-radius: 3px; | 155 | border-radius: 3px; |
173 | height: fit-content; | 156 | height: fit-content; |
@@ -245,6 +228,7 @@ | |||
245 | .vjs-next-video, | 228 | .vjs-next-video, |
246 | .vjs-previous-video { | 229 | .vjs-previous-video { |
247 | width: $control-bar-button-width - 4px; | 230 | width: $control-bar-button-width - 4px; |
231 | cursor: pointer; | ||
248 | 232 | ||
249 | &.vjs-disabled { | 233 | &.vjs-disabled { |
250 | cursor: default; | 234 | cursor: default; |
diff --git a/client/src/sass/player/index.scss b/client/src/sass/player/index.scss index 5d0307d95..4bfd67a26 100644 --- a/client/src/sass/player/index.scss +++ b/client/src/sass/player/index.scss | |||
@@ -10,3 +10,4 @@ | |||
10 | @use './playlist'; | 10 | @use './playlist'; |
11 | @use './stats'; | 11 | @use './stats'; |
12 | @use './offline-notification'; | 12 | @use './offline-notification'; |
13 | @use './storyboard.scss'; | ||
diff --git a/client/src/sass/player/mobile.scss b/client/src/sass/player/mobile.scss index d150c54ee..b0019d2c9 100644 --- a/client/src/sass/player/mobile.scss +++ b/client/src/sass/player/mobile.scss | |||
@@ -170,7 +170,8 @@ | |||
170 | } | 170 | } |
171 | } | 171 | } |
172 | 172 | ||
173 | &.vjs-scrubbing { | 173 | &.vjs-scrubbing, |
174 | &.vjs-mobile-sliding { | ||
174 | .vjs-mobile-buttons-overlay { | 175 | .vjs-mobile-buttons-overlay { |
175 | display: none; | 176 | display: none; |
176 | } | 177 | } |
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss index 4df8dbaf0..572ae7050 100644 --- a/client/src/sass/player/peertube-skin.scss +++ b/client/src/sass/player/peertube-skin.scss | |||
@@ -84,7 +84,9 @@ body { | |||
84 | } | 84 | } |
85 | 85 | ||
86 | // Do not display poster when video is starting | 86 | // Do not display poster when video is starting |
87 | &.vjs-has-autoplay:not(.vjs-has-started) { | 87 | // Or if we change resolution manually |
88 | &.vjs-has-autoplay:not(.vjs-has-started), | ||
89 | &.vjs-updating-resolution { | ||
88 | .vjs-poster { | 90 | .vjs-poster { |
89 | opacity: 0; | 91 | opacity: 0; |
90 | visibility: hidden; | 92 | visibility: hidden; |
diff --git a/client/src/sass/player/settings-menu.scss b/client/src/sass/player/settings-menu.scss index d2346c126..369c827f7 100644 --- a/client/src/sass/player/settings-menu.scss +++ b/client/src/sass/player/settings-menu.scss | |||
@@ -75,6 +75,7 @@ $setting-transition-easing: ease-out; | |||
75 | > .vjs-menu { | 75 | > .vjs-menu { |
76 | flex: 1; | 76 | flex: 1; |
77 | min-width: 200px; | 77 | min-width: 200px; |
78 | padding: 5px 0; | ||
78 | } | 79 | } |
79 | 80 | ||
80 | > .vjs-menu, | 81 | > .vjs-menu, |
@@ -90,14 +91,6 @@ $setting-transition-easing: ease-out; | |||
90 | background-color: rgba(255, 255, 255, 0.2); | 91 | background-color: rgba(255, 255, 255, 0.2); |
91 | } | 92 | } |
92 | 93 | ||
93 | &:first-child { | ||
94 | margin-top: 5px; | ||
95 | } | ||
96 | |||
97 | &:last-child { | ||
98 | margin-bottom: 5px; | ||
99 | } | ||
100 | |||
101 | &.disabled { | 94 | &.disabled { |
102 | opacity: 0.5; | 95 | opacity: 0.5; |
103 | cursor: default !important; | 96 | cursor: default !important; |
diff --git a/client/src/sass/player/storyboard.scss b/client/src/sass/player/storyboard.scss new file mode 100644 index 000000000..c80d1b59d --- /dev/null +++ b/client/src/sass/player/storyboard.scss | |||
@@ -0,0 +1,26 @@ | |||
1 | @use 'sass:math'; | ||
2 | @use '_variables' as *; | ||
3 | @use '_mixins' as *; | ||
4 | @use './_player-variables' as *; | ||
5 | |||
6 | // Like the time tooltip | ||
7 | .video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder { | ||
8 | display: none; | ||
9 | } | ||
10 | |||
11 | .video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder, | ||
12 | .video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder { | ||
13 | display: block; | ||
14 | |||
15 | // Ensure that we maintain a font-size of ~10px. | ||
16 | font-size: 0.6em; | ||
17 | visibility: visible; | ||
18 | } | ||
19 | |||
20 | .video-js.vjs-settings-dialog-opened { | ||
21 | .vjs-storyboard-sprite-placeholder, | ||
22 | .vjs-time-tooltip, | ||
23 | .vjs-mouse-display { | ||
24 | display: none !important; | ||
25 | } | ||
26 | } | ||
diff --git a/client/src/shims/http.ts b/client/src/shims/http.ts deleted file mode 100644 index 1b1767aab..000000000 --- a/client/src/shims/http.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | module.exports = require('stream-http') | ||
diff --git a/client/src/shims/https.ts b/client/src/shims/https.ts deleted file mode 100644 index f5ef70430..000000000 --- a/client/src/shims/https.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | module.exports = require('https-browserify') | ||
diff --git a/client/src/shims/stream.ts b/client/src/shims/stream.ts deleted file mode 100644 index 977fd05a0..000000000 --- a/client/src/shims/stream.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | module.exports = require('stream-browserify') | ||
diff --git a/client/src/standalone/player/.npmignore b/client/src/standalone/embed-player-api/.npmignore index 870b6315b..870b6315b 100644 --- a/client/src/standalone/player/.npmignore +++ b/client/src/standalone/embed-player-api/.npmignore | |||
diff --git a/client/src/standalone/player/README.md b/client/src/standalone/embed-player-api/README.md index 7b47e8f02..7b47e8f02 100644 --- a/client/src/standalone/player/README.md +++ b/client/src/standalone/embed-player-api/README.md | |||
diff --git a/client/src/standalone/player/definitions.ts b/client/src/standalone/embed-player-api/definitions.ts index 495f1a98c..495f1a98c 100644 --- a/client/src/standalone/player/definitions.ts +++ b/client/src/standalone/embed-player-api/definitions.ts | |||
diff --git a/client/src/standalone/player/events.ts b/client/src/standalone/embed-player-api/events.ts index 77d21c78c..77d21c78c 100644 --- a/client/src/standalone/player/events.ts +++ b/client/src/standalone/embed-player-api/events.ts | |||
diff --git a/client/src/standalone/player/package.json b/client/src/standalone/embed-player-api/package.json index b549fbf52..b549fbf52 100644 --- a/client/src/standalone/player/package.json +++ b/client/src/standalone/embed-player-api/package.json | |||
diff --git a/client/src/standalone/player/player.ts b/client/src/standalone/embed-player-api/player.ts index 75487258b..75487258b 100644 --- a/client/src/standalone/player/player.ts +++ b/client/src/standalone/embed-player-api/player.ts | |||
diff --git a/client/src/standalone/player/tsconfig.json b/client/src/standalone/embed-player-api/tsconfig.json index eecc63dfb..eecc63dfb 100644 --- a/client/src/standalone/player/tsconfig.json +++ b/client/src/standalone/embed-player-api/tsconfig.json | |||
diff --git a/client/src/standalone/player/webpack.config.js b/client/src/standalone/embed-player-api/webpack.config.js index 48d350edf..48d350edf 100644 --- a/client/src/standalone/player/webpack.config.js +++ b/client/src/standalone/embed-player-api/webpack.config.js | |||
diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts index a99f1edae..6227c378e 100644 --- a/client/src/standalone/videos/embed-api.ts +++ b/client/src/standalone/videos/embed-api.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import './embed.scss' | 1 | import './embed.scss' |
2 | import * as Channel from 'jschannel' | 2 | import * as Channel from 'jschannel' |
3 | import { logger } from '../../root-helpers' | 3 | import { logger } from '../../root-helpers' |
4 | import { PeerTubeResolution, PeerTubeTextTrack } from '../player/definitions' | 4 | import { PeerTubeResolution, PeerTubeTextTrack } from '../embed-player-api/definitions' |
5 | import { PeerTubeEmbed } from './embed' | 5 | import { PeerTubeEmbed } from './embed' |
6 | 6 | ||
7 | /** | 7 | /** |
@@ -72,15 +72,12 @@ export class PeerTubeEmbedApi { | |||
72 | private setResolution (resolutionId: number) { | 72 | private setResolution (resolutionId: number) { |
73 | logger.info(`Set resolution ${resolutionId}`) | 73 | logger.info(`Set resolution ${resolutionId}`) |
74 | 74 | ||
75 | if (this.isWebtorrent()) { | 75 | if (this.isWebVideo() && resolutionId === -1) { |
76 | if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionPossible() === false) return | 76 | logger.error('Auto resolution cannot be set in web video player mode') |
77 | |||
78 | this.embed.player.webtorrent().changeQuality(resolutionId) | ||
79 | |||
80 | return | 77 | return |
81 | } | 78 | } |
82 | 79 | ||
83 | this.embed.player.p2pMediaLoader().getHLSJS().currentLevel = resolutionId | 80 | this.embed.player.peertubeResolutions().select({ id: resolutionId, fireCallback: true }) |
84 | } | 81 | } |
85 | 82 | ||
86 | private getCaptions (): PeerTubeTextTrack[] { | 83 | private getCaptions (): PeerTubeTextTrack[] { |
@@ -152,8 +149,8 @@ export class PeerTubeEmbedApi { | |||
152 | // --------------------------------------------------------------------------- | 149 | // --------------------------------------------------------------------------- |
153 | 150 | ||
154 | // PeerTube specific capabilities | 151 | // PeerTube specific capabilities |
155 | this.embed.player.peertubeResolutions().on('resolutionsAdded', () => this.loadResolutions()) | 152 | this.embed.player.peertubeResolutions().on('resolutions-added', () => this.loadResolutions()) |
156 | this.embed.player.peertubeResolutions().on('resolutionChanged', () => this.loadResolutions()) | 153 | this.embed.player.peertubeResolutions().on('resolutions-changed', () => this.loadResolutions()) |
157 | 154 | ||
158 | this.loadResolutions() | 155 | this.loadResolutions() |
159 | 156 | ||
@@ -193,7 +190,7 @@ export class PeerTubeEmbedApi { | |||
193 | }) | 190 | }) |
194 | } | 191 | } |
195 | 192 | ||
196 | private isWebtorrent () { | 193 | private isWebVideo () { |
197 | return !!this.embed.player.webtorrent | 194 | return !!this.embed.player.webVideo |
198 | } | 195 | } |
199 | } | 196 | } |
diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html index a74bb4cee..e2dc02b60 100644 --- a/client/src/standalone/videos/embed.html +++ b/client/src/standalone/videos/embed.html | |||
@@ -44,11 +44,11 @@ | |||
44 | <div id="video-password-block"> | 44 | <div id="video-password-block"> |
45 | <!-- eslint-disable-next-line @angular-eslint/template/elements-content --> | 45 | <!-- eslint-disable-next-line @angular-eslint/template/elements-content --> |
46 | <h1 id="video-password-title"></h1> | 46 | <h1 id="video-password-title"></h1> |
47 | 47 | ||
48 | <div id="video-password-content"></div> | 48 | <div id="video-password-content"></div> |
49 | 49 | ||
50 | <form id="video-password-form"> | 50 | <form id="video-password-form"> |
51 | <input type="password" id="video-password-input" name="video-password" required> | 51 | <input type="password" id="video-password-input" name="video-password" autocomplete="user-password" required> |
52 | <button type="submit" id="video-password-submit"> </button> | 52 | <button type="submit" id="video-password-submit"> </button> |
53 | </form> | 53 | </form> |
54 | 54 | ||
@@ -60,8 +60,6 @@ | |||
60 | 60 | ||
61 | <div id="video-wrapper"></div> | 61 | <div id="video-wrapper"></div> |
62 | 62 | ||
63 | <div id="placeholder-preview"></div> | ||
64 | |||
65 | <script type="text/javascript"> | 63 | <script type="text/javascript"> |
66 | // Can be called in embed.ts | 64 | // Can be called in embed.ts |
67 | window.displayIncompatibleBrowser = function () { | 65 | window.displayIncompatibleBrowser = function () { |
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 6e37ce193..78b812ffd 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts | |||
@@ -3,7 +3,6 @@ import '../../assets/player/shared/dock/peertube-dock-component' | |||
3 | import '../../assets/player/shared/dock/peertube-dock-plugin' | 3 | import '../../assets/player/shared/dock/peertube-dock-plugin' |
4 | import { PeerTubeServerError } from 'src/types' | 4 | import { PeerTubeServerError } from 'src/types' |
5 | import videojs from 'video.js' | 5 | import videojs from 'video.js' |
6 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' | ||
7 | import { | 6 | import { |
8 | HTMLServerConfig, | 7 | HTMLServerConfig, |
9 | ResultList, | 8 | ResultList, |
@@ -13,7 +12,7 @@ import { | |||
13 | VideoPlaylistElement, | 12 | VideoPlaylistElement, |
14 | VideoState | 13 | VideoState |
15 | } from '../../../../shared/models' | 14 | } from '../../../../shared/models' |
16 | import { PeertubePlayerManager } from '../../assets/player' | 15 | import { PeerTubePlayer } from '../../assets/player/peertube-player' |
17 | import { TranslationsManager } from '../../assets/player/translations-manager' | 16 | import { TranslationsManager } from '../../assets/player/translations-manager' |
18 | import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers' | 17 | import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers' |
19 | import { PeerTubeEmbedApi } from './embed-api' | 18 | import { PeerTubeEmbedApi } from './embed-api' |
@@ -21,7 +20,7 @@ import { | |||
21 | AuthHTTP, | 20 | AuthHTTP, |
22 | LiveManager, | 21 | LiveManager, |
23 | PeerTubePlugin, | 22 | PeerTubePlugin, |
24 | PlayerManagerOptions, | 23 | PlayerOptionsBuilder, |
25 | PlaylistFetcher, | 24 | PlaylistFetcher, |
26 | PlaylistTracker, | 25 | PlaylistTracker, |
27 | Translations, | 26 | Translations, |
@@ -36,17 +35,23 @@ export class PeerTubeEmbed { | |||
36 | config: HTMLServerConfig | 35 | config: HTMLServerConfig |
37 | 36 | ||
38 | private translationsPromise: Promise<{ [id: string]: string }> | 37 | private translationsPromise: Promise<{ [id: string]: string }> |
39 | private PeertubePlayerManagerModulePromise: Promise<any> | 38 | private PeerTubePlayerManagerModulePromise: Promise<any> |
40 | 39 | ||
41 | private readonly http: AuthHTTP | 40 | private readonly http: AuthHTTP |
42 | private readonly videoFetcher: VideoFetcher | 41 | private readonly videoFetcher: VideoFetcher |
43 | private readonly playlistFetcher: PlaylistFetcher | 42 | private readonly playlistFetcher: PlaylistFetcher |
44 | private readonly peertubePlugin: PeerTubePlugin | 43 | private readonly peertubePlugin: PeerTubePlugin |
45 | private readonly playerHTML: PlayerHTML | 44 | private readonly playerHTML: PlayerHTML |
46 | private readonly playerManagerOptions: PlayerManagerOptions | 45 | private readonly playerOptionsBuilder: PlayerOptionsBuilder |
47 | private readonly liveManager: LiveManager | 46 | private readonly liveManager: LiveManager |
48 | 47 | ||
48 | private peertubePlayer: PeerTubePlayer | ||
49 | |||
49 | private playlistTracker: PlaylistTracker | 50 | private playlistTracker: PlaylistTracker |
51 | |||
52 | private alreadyInitialized = false | ||
53 | private alreadyPlayed = false | ||
54 | |||
50 | private videoPassword: string | 55 | private videoPassword: string |
51 | private requiresPassword: boolean | 56 | private requiresPassword: boolean |
52 | 57 | ||
@@ -59,7 +64,7 @@ export class PeerTubeEmbed { | |||
59 | this.playlistFetcher = new PlaylistFetcher(this.http) | 64 | this.playlistFetcher = new PlaylistFetcher(this.http) |
60 | this.peertubePlugin = new PeerTubePlugin(this.http) | 65 | this.peertubePlugin = new PeerTubePlugin(this.http) |
61 | this.playerHTML = new PlayerHTML(videoWrapperId) | 66 | this.playerHTML = new PlayerHTML(videoWrapperId) |
62 | this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) | 67 | this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin) |
63 | this.liveManager = new LiveManager(this.playerHTML) | 68 | this.liveManager = new LiveManager(this.playerHTML) |
64 | this.requiresPassword = false | 69 | this.requiresPassword = false |
65 | 70 | ||
@@ -81,14 +86,14 @@ export class PeerTubeEmbed { | |||
81 | } | 86 | } |
82 | 87 | ||
83 | getScope () { | 88 | getScope () { |
84 | return this.playerManagerOptions.getScope() | 89 | return this.playerOptionsBuilder.getScope() |
85 | } | 90 | } |
86 | 91 | ||
87 | // --------------------------------------------------------------------------- | 92 | // --------------------------------------------------------------------------- |
88 | 93 | ||
89 | async init () { | 94 | async init () { |
90 | this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) | 95 | this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) |
91 | this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager') | 96 | this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player') |
92 | 97 | ||
93 | // Issue when we parsed config from HTML, fallback to API | 98 | // Issue when we parsed config from HTML, fallback to API |
94 | if (!this.config) { | 99 | if (!this.config) { |
@@ -102,7 +107,7 @@ export class PeerTubeEmbed { | |||
102 | 107 | ||
103 | if (!videoId) return | 108 | if (!videoId) return |
104 | 109 | ||
105 | return this.loadVideoAndBuildPlayer({ uuid: videoId, autoplayFromPreviousVideo: false, forceAutoplay: false }) | 110 | return this.loadVideoAndBuildPlayer({ uuid: videoId, forceAutoplay: false }) |
106 | } | 111 | } |
107 | 112 | ||
108 | private async initPlaylist () { | 113 | private async initPlaylist () { |
@@ -137,7 +142,7 @@ export class PeerTubeEmbed { | |||
137 | } | 142 | } |
138 | 143 | ||
139 | private initializeApi () { | 144 | private initializeApi () { |
140 | if (this.playerManagerOptions.hasAPIEnabled()) { | 145 | if (this.playerOptionsBuilder.hasAPIEnabled()) { |
141 | if (this.api) { | 146 | if (this.api) { |
142 | this.api.reInit() | 147 | this.api.reInit() |
143 | return | 148 | return |
@@ -159,7 +164,7 @@ export class PeerTubeEmbed { | |||
159 | 164 | ||
160 | this.playlistTracker.setCurrentElement(next) | 165 | this.playlistTracker.setCurrentElement(next) |
161 | 166 | ||
162 | return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) | 167 | return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, forceAutoplay: false }) |
163 | } | 168 | } |
164 | 169 | ||
165 | async playPreviousPlaylistVideo () { | 170 | async playPreviousPlaylistVideo () { |
@@ -171,7 +176,7 @@ export class PeerTubeEmbed { | |||
171 | 176 | ||
172 | this.playlistTracker.setCurrentElement(previous) | 177 | this.playlistTracker.setCurrentElement(previous) |
173 | 178 | ||
174 | await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) | 179 | await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, forceAutoplay: false }) |
175 | } | 180 | } |
176 | 181 | ||
177 | getCurrentPlaylistPosition () { | 182 | getCurrentPlaylistPosition () { |
@@ -182,10 +187,9 @@ export class PeerTubeEmbed { | |||
182 | 187 | ||
183 | private async loadVideoAndBuildPlayer (options: { | 188 | private async loadVideoAndBuildPlayer (options: { |
184 | uuid: string | 189 | uuid: string |
185 | autoplayFromPreviousVideo: boolean | ||
186 | forceAutoplay: boolean | 190 | forceAutoplay: boolean |
187 | }) { | 191 | }) { |
188 | const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options | 192 | const { uuid, forceAutoplay } = options |
189 | 193 | ||
190 | try { | 194 | try { |
191 | const { | 195 | const { |
@@ -194,7 +198,7 @@ export class PeerTubeEmbed { | |||
194 | storyboardsPromise | 198 | storyboardsPromise |
195 | } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) | 199 | } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) |
196 | 200 | ||
197 | return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, autoplayFromPreviousVideo, forceAutoplay }) | 201 | return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay }) |
198 | } catch (err) { | 202 | } catch (err) { |
199 | 203 | ||
200 | if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) | 204 | if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) |
@@ -206,20 +210,14 @@ export class PeerTubeEmbed { | |||
206 | videoResponse: Response | 210 | videoResponse: Response |
207 | storyboardsPromise: Promise<Response> | 211 | storyboardsPromise: Promise<Response> |
208 | captionsPromise: Promise<Response> | 212 | captionsPromise: Promise<Response> |
209 | autoplayFromPreviousVideo: boolean | ||
210 | forceAutoplay: boolean | 213 | forceAutoplay: boolean |
211 | }) { | 214 | }) { |
212 | const { videoResponse, captionsPromise, storyboardsPromise, autoplayFromPreviousVideo, forceAutoplay } = options | 215 | const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options |
213 | |||
214 | this.resetPlayerElement() | ||
215 | 216 | ||
216 | const videoInfoPromise = videoResponse.json() | 217 | const videoInfoPromise = videoResponse.json() |
217 | .then(async (videoInfo: VideoDetails) => { | 218 | .then(async (videoInfo: VideoDetails) => { |
218 | this.playerManagerOptions.loadParams(this.config, videoInfo) | 219 | this.playerOptionsBuilder.loadParams(this.config, videoInfo) |
219 | 220 | ||
220 | if (!autoplayFromPreviousVideo && !this.playerManagerOptions.hasAutoplay()) { | ||
221 | this.playerHTML.buildPlaceholder(videoInfo) | ||
222 | } | ||
223 | const live = videoInfo.isLive | 221 | const live = videoInfo.isLive |
224 | ? await this.videoFetcher.loadLive(videoInfo) | 222 | ? await this.videoFetcher.loadLive(videoInfo) |
225 | : undefined | 223 | : undefined |
@@ -235,89 +233,78 @@ export class PeerTubeEmbed { | |||
235 | { video, live, videoFileToken }, | 233 | { video, live, videoFileToken }, |
236 | translations, | 234 | translations, |
237 | captionsResponse, | 235 | captionsResponse, |
238 | storyboardsResponse, | 236 | storyboardsResponse |
239 | PeertubePlayerManagerModule | ||
240 | ] = await Promise.all([ | 237 | ] = await Promise.all([ |
241 | videoInfoPromise, | 238 | videoInfoPromise, |
242 | this.translationsPromise, | 239 | this.translationsPromise, |
243 | captionsPromise, | 240 | captionsPromise, |
244 | storyboardsPromise, | 241 | storyboardsPromise, |
245 | this.PeertubePlayerManagerModulePromise | 242 | this.buildPlayerIfNeeded() |
246 | ]) | 243 | ]) |
247 | 244 | ||
248 | await this.peertubePlugin.loadPlugins(this.config, translations) | 245 | // If already played, we are in a playlist so we don't want to display the poster between videos |
246 | if (!this.alreadyPlayed) { | ||
247 | this.peertubePlayer.setPoster(window.location.origin + video.previewPath) | ||
248 | } | ||
249 | 249 | ||
250 | const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager | 250 | const playlist = this.playlistTracker |
251 | ? { | ||
252 | onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, forceAutoplay: false }), | ||
251 | 253 | ||
252 | const playerOptions = await this.playerManagerOptions.getPlayerOptions({ | 254 | playlistTracker: this.playlistTracker, |
255 | playNext: () => this.playNextPlaylistVideo(), | ||
256 | playPrevious: () => this.playPreviousPlaylistVideo() | ||
257 | } | ||
258 | : undefined | ||
259 | |||
260 | const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({ | ||
253 | video, | 261 | video, |
254 | captionsResponse, | 262 | captionsResponse, |
255 | autoplayFromPreviousVideo, | ||
256 | translations, | 263 | translations, |
257 | serverConfig: this.config, | ||
258 | 264 | ||
259 | storyboardsResponse, | 265 | storyboardsResponse, |
260 | 266 | ||
261 | authorizationHeader: () => this.http.getHeaderTokenValue(), | ||
262 | videoFileToken: () => videoFileToken, | 267 | videoFileToken: () => videoFileToken, |
263 | videoPassword: () => this.videoPassword, | 268 | videoPassword: () => this.videoPassword, |
264 | requiresPassword: this.requiresPassword, | 269 | requiresPassword: this.requiresPassword, |
265 | 270 | ||
266 | onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), | 271 | playlist, |
267 | |||
268 | playlistTracker: this.playlistTracker, | ||
269 | playNextPlaylistVideo: () => this.playNextPlaylistVideo(), | ||
270 | playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(), | ||
271 | 272 | ||
272 | live, | 273 | live, |
273 | forceAutoplay | 274 | forceAutoplay, |
275 | alreadyPlayed: this.alreadyPlayed | ||
274 | }) | 276 | }) |
277 | await this.peertubePlayer.load(loadOptions) | ||
275 | 278 | ||
276 | this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), playerOptions, (player: videojs.Player) => { | 279 | if (!this.alreadyInitialized) { |
277 | this.player = player | 280 | this.player = this.peertubePlayer.getPlayer(); |
278 | }) | ||
279 | 281 | ||
280 | this.player.on('customError', (event: any, data: any) => { | 282 | (window as any)['videojsPlayer'] = this.player |
281 | const message = data?.err?.message || '' | ||
282 | if (!message.includes('from xs param')) return | ||
283 | 283 | ||
284 | this.player.dispose() | 284 | this.buildCSS() |
285 | this.playerHTML.removePlayerElement() | 285 | this.initializeApi() |
286 | this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations) | 286 | } |
287 | }); | ||
288 | 287 | ||
289 | (window as any)['videojsPlayer'] = this.player | 288 | this.alreadyInitialized = true |
290 | 289 | ||
291 | this.buildCSS() | 290 | this.player.one('play', () => { |
292 | this.buildPlayerDock(video) | 291 | this.alreadyPlayed = true |
293 | this.initializeApi() | 292 | }) |
294 | 293 | ||
295 | this.playerHTML.removePlaceholder() | ||
296 | if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock() | 294 | if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock() |
297 | 295 | ||
298 | if (this.isPlaylistEmbed()) { | ||
299 | await this.buildPlayerPlaylistUpnext() | ||
300 | |||
301 | this.player.playlist().updateSelected() | ||
302 | |||
303 | this.player.on('stopped', () => { | ||
304 | this.playNextPlaylistVideo() | ||
305 | }) | ||
306 | } | ||
307 | |||
308 | if (video.isLive) { | 296 | if (video.isLive) { |
309 | this.liveManager.listenForChanges({ | 297 | this.liveManager.listenForChanges({ |
310 | video, | 298 | video, |
311 | onPublishedVideo: () => { | 299 | onPublishedVideo: () => { |
312 | this.liveManager.stopListeningForChanges(video) | 300 | this.liveManager.stopListeningForChanges(video) |
313 | this.loadVideoAndBuildPlayer({ uuid: video.uuid, autoplayFromPreviousVideo: false, forceAutoplay: true }) | 301 | this.loadVideoAndBuildPlayer({ uuid: video.uuid, forceAutoplay: true }) |
314 | } | 302 | } |
315 | }) | 303 | }) |
316 | 304 | ||
317 | if (video.state.id === VideoState.WAITING_FOR_LIVE || video.state.id === VideoState.LIVE_ENDED) { | 305 | if (video.state.id === VideoState.WAITING_FOR_LIVE || video.state.id === VideoState.LIVE_ENDED) { |
318 | this.liveManager.displayInfo({ state: video.state.id, translations }) | 306 | this.liveManager.displayInfo({ state: video.state.id, translations }) |
319 | 307 | this.peertubePlayer.disable() | |
320 | this.disablePlayer() | ||
321 | } else { | 308 | } else { |
322 | this.correctlyHandleLiveEnding(translations) | 309 | this.correctlyHandleLiveEnding(translations) |
323 | } | 310 | } |
@@ -326,74 +313,15 @@ export class PeerTubeEmbed { | |||
326 | this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video }) | 313 | this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video }) |
327 | } | 314 | } |
328 | 315 | ||
329 | private resetPlayerElement () { | ||
330 | if (this.player) { | ||
331 | this.player.dispose() | ||
332 | this.player = undefined | ||
333 | } | ||
334 | |||
335 | const playerElement = document.createElement('video') | ||
336 | playerElement.className = 'video-js vjs-peertube-skin' | ||
337 | playerElement.setAttribute('playsinline', 'true') | ||
338 | |||
339 | this.playerHTML.setPlayerElement(playerElement) | ||
340 | this.playerHTML.addPlayerElementToDOM() | ||
341 | } | ||
342 | |||
343 | private async buildPlayerPlaylistUpnext () { | ||
344 | const translations = await this.translationsPromise | ||
345 | |||
346 | this.player.upnext({ | ||
347 | timeout: 10000, // 10s | ||
348 | headText: peertubeTranslate('Up Next', translations), | ||
349 | cancelText: peertubeTranslate('Cancel', translations), | ||
350 | suspendedText: peertubeTranslate('Autoplay is suspended', translations), | ||
351 | getTitle: () => this.playlistTracker.nextVideoTitle(), | ||
352 | next: () => this.playNextPlaylistVideo(), | ||
353 | condition: () => !!this.playlistTracker.getNextPlaylistElement(), | ||
354 | suspended: () => false | ||
355 | }) | ||
356 | } | ||
357 | |||
358 | private buildPlayerDock (videoInfo: VideoDetails) { | ||
359 | if (!this.playerManagerOptions.hasControls()) return | ||
360 | |||
361 | // On webtorrent fallback, player may have been disposed | ||
362 | if (!this.player.player_) return | ||
363 | |||
364 | const title = this.playerManagerOptions.hasTitle() | ||
365 | ? videoInfo.name | ||
366 | : undefined | ||
367 | |||
368 | const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled() | ||
369 | ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>' | ||
370 | : undefined | ||
371 | |||
372 | if (!title && !description) return | ||
373 | |||
374 | const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50) | ||
375 | const avatar = availableAvatars.length !== 0 | ||
376 | ? availableAvatars[0] | ||
377 | : undefined | ||
378 | |||
379 | this.player.peertubeDock({ | ||
380 | title, | ||
381 | description, | ||
382 | avatarUrl: title && avatar | ||
383 | ? avatar.path | ||
384 | : undefined | ||
385 | }) | ||
386 | } | ||
387 | |||
388 | private buildCSS () { | 316 | private buildCSS () { |
389 | const body = document.getElementById('custom-css') | 317 | const body = document.getElementById('custom-css') |
390 | 318 | ||
391 | if (this.playerManagerOptions.hasBigPlayBackgroundColor()) { | 319 | if (this.playerOptionsBuilder.hasBigPlayBackgroundColor()) { |
392 | body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor()) | 320 | body.style.setProperty('--embedBigPlayBackgroundColor', this.playerOptionsBuilder.getBigPlayBackgroundColor()) |
393 | } | 321 | } |
394 | 322 | ||
395 | if (this.playerManagerOptions.hasForegroundColor()) { | 323 | if (this.playerOptionsBuilder.hasForegroundColor()) { |
396 | body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor()) | 324 | body.style.setProperty('--embedForegroundColor', this.playerOptionsBuilder.getForegroundColor()) |
397 | } | 325 | } |
398 | } | 326 | } |
399 | 327 | ||
@@ -415,23 +343,10 @@ export class PeerTubeEmbed { | |||
415 | // Display the live ended information | 343 | // Display the live ended information |
416 | this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations }) | 344 | this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations }) |
417 | 345 | ||
418 | this.disablePlayer() | 346 | this.peertubePlayer.disable() |
419 | }) | 347 | }) |
420 | } | 348 | } |
421 | 349 | ||
422 | private disablePlayer () { | ||
423 | if (this.player.isFullscreen()) { | ||
424 | this.player.exitFullscreen() | ||
425 | } | ||
426 | |||
427 | // Disable player | ||
428 | this.player.hasStarted(false) | ||
429 | this.player.removeClass('vjs-has-autoplay') | ||
430 | this.player.bigPlayButton.hide(); | ||
431 | |||
432 | (this.player.el() as HTMLElement).style.pointerEvents = 'none' | ||
433 | } | ||
434 | |||
435 | private async handlePasswordError (err: PeerTubeServerError) { | 350 | private async handlePasswordError (err: PeerTubeServerError) { |
436 | let incorrectPassword: boolean = null | 351 | let incorrectPassword: boolean = null |
437 | if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false | 352 | if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false |
@@ -447,6 +362,33 @@ export class PeerTubeEmbed { | |||
447 | return true | 362 | return true |
448 | } | 363 | } |
449 | 364 | ||
365 | private async buildPlayerIfNeeded () { | ||
366 | if (this.peertubePlayer) { | ||
367 | this.peertubePlayer.enable() | ||
368 | |||
369 | return | ||
370 | } | ||
371 | |||
372 | const playerElement = document.createElement('video') | ||
373 | playerElement.className = 'video-js vjs-peertube-skin' | ||
374 | playerElement.setAttribute('playsinline', 'true') | ||
375 | |||
376 | this.playerHTML.setPlayerElement(playerElement) | ||
377 | this.playerHTML.addPlayerElementToDOM() | ||
378 | |||
379 | const [ { PeerTubePlayer } ] = await Promise.all([ | ||
380 | this.PeerTubePlayerManagerModulePromise, | ||
381 | this.peertubePlugin.loadPlugins(this.config, await this.translationsPromise) | ||
382 | ]) | ||
383 | |||
384 | const constructorOptions = this.playerOptionsBuilder.getPlayerConstructorOptions({ | ||
385 | serverConfig: this.config, | ||
386 | authorizationHeader: () => this.http.getHeaderTokenValue() | ||
387 | }) | ||
388 | this.peertubePlayer = new PeerTubePlayer(constructorOptions) | ||
389 | |||
390 | this.player = this.peertubePlayer.getPlayer() | ||
391 | } | ||
450 | } | 392 | } |
451 | 393 | ||
452 | PeerTubeEmbed.main() | 394 | PeerTubeEmbed.main() |
diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts index 928b8e270..dcc522ac6 100644 --- a/client/src/standalone/videos/shared/index.ts +++ b/client/src/standalone/videos/shared/index.ts | |||
@@ -2,7 +2,7 @@ export * from './auth-http' | |||
2 | export * from './peertube-plugin' | 2 | export * from './peertube-plugin' |
3 | export * from './live-manager' | 3 | export * from './live-manager' |
4 | export * from './player-html' | 4 | export * from './player-html' |
5 | export * from './player-manager-options' | 5 | export * from './player-options-builder' |
6 | export * from './playlist-fetcher' | 6 | export * from './playlist-fetcher' |
7 | export * from './playlist-tracker' | 7 | export * from './playlist-tracker' |
8 | export * from './translations' | 8 | export * from './translations' |
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts index a0846d9d7..0defa0d70 100644 --- a/client/src/standalone/videos/shared/player-html.ts +++ b/client/src/standalone/videos/shared/player-html.ts | |||
@@ -1,5 +1,4 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | 1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' |
2 | import { VideoDetails } from '../../../../../shared/models' | ||
3 | import { logger } from '../../../root-helpers' | 2 | import { logger } from '../../../root-helpers' |
4 | import { Translations } from './translations' | 3 | import { Translations } from './translations' |
5 | 4 | ||
@@ -59,7 +58,6 @@ export class PlayerHTML { | |||
59 | const { incorrectPassword, translations } = options | 58 | const { incorrectPassword, translations } = options |
60 | return new Promise((resolve) => { | 59 | return new Promise((resolve) => { |
61 | 60 | ||
62 | this.removePlaceholder() | ||
63 | this.wrapperElement.style.display = 'none' | 61 | this.wrapperElement.style.display = 'none' |
64 | 62 | ||
65 | const translatedTitle = peertubeTranslate('This video is password protected', translations) | 63 | const translatedTitle = peertubeTranslate('This video is password protected', translations) |
@@ -107,19 +105,6 @@ export class PlayerHTML { | |||
107 | this.wrapperElement.style.display = 'block' | 105 | this.wrapperElement.style.display = 'block' |
108 | } | 106 | } |
109 | 107 | ||
110 | buildPlaceholder (video: VideoDetails) { | ||
111 | const placeholder = this.getPlaceholderElement() | ||
112 | |||
113 | const url = window.location.origin + video.previewPath | ||
114 | placeholder.style.backgroundImage = `url("${url}")` | ||
115 | placeholder.style.display = 'block' | ||
116 | } | ||
117 | |||
118 | removePlaceholder () { | ||
119 | const placeholder = this.getPlaceholderElement() | ||
120 | placeholder.style.display = 'none' | ||
121 | } | ||
122 | |||
123 | displayInformation (text: string, translations: Translations) { | 108 | displayInformation (text: string, translations: Translations) { |
124 | if (this.informationElement) this.removeInformation() | 109 | if (this.informationElement) this.removeInformation() |
125 | 110 | ||
@@ -137,10 +122,6 @@ export class PlayerHTML { | |||
137 | this.informationElement = undefined | 122 | this.informationElement = undefined |
138 | } | 123 | } |
139 | 124 | ||
140 | private getPlaceholderElement () { | ||
141 | return document.getElementById('placeholder-preview') | ||
142 | } | ||
143 | |||
144 | private removeElement (element: HTMLElement) { | 125 | private removeElement (element: HTMLElement) { |
145 | element.parentElement.removeChild(element) | 126 | element.parentElement.removeChild(element) |
146 | } | 127 | } |
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-options-builder.ts index 3c7521bc2..8a4e32444 100644 --- a/client/src/standalone/videos/shared/player-manager-options.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts | |||
@@ -10,7 +10,7 @@ import { | |||
10 | VideoState, | 10 | VideoState, |
11 | VideoStreamingPlaylistType | 11 | VideoStreamingPlaylistType |
12 | } from '../../../../../shared/models' | 12 | } from '../../../../../shared/models' |
13 | import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' | 13 | import { HLSOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' |
14 | import { | 14 | import { |
15 | getBoolOrDefault, | 15 | getBoolOrDefault, |
16 | getParamString, | 16 | getParamString, |
@@ -27,7 +27,7 @@ import { PlaylistTracker } from './playlist-tracker' | |||
27 | import { Translations } from './translations' | 27 | import { Translations } from './translations' |
28 | import { VideoFetcher } from './video-fetcher' | 28 | import { VideoFetcher } from './video-fetcher' |
29 | 29 | ||
30 | export class PlayerManagerOptions { | 30 | export class PlayerOptionsBuilder { |
31 | private autoplay: boolean | 31 | private autoplay: boolean |
32 | 32 | ||
33 | private controls: boolean | 33 | private controls: boolean |
@@ -141,10 +141,10 @@ export class PlayerManagerOptions { | |||
141 | 141 | ||
142 | if (modeParam) { | 142 | if (modeParam) { |
143 | if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' | 143 | if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' |
144 | else this.mode = 'webtorrent' | 144 | else this.mode = 'web-video' |
145 | } else { | 145 | } else { |
146 | if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader' | 146 | if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader' |
147 | else this.mode = 'webtorrent' | 147 | else this.mode = 'web-video' |
148 | } | 148 | } |
149 | } catch (err) { | 149 | } catch (err) { |
150 | logger.error('Cannot get params from URL.', err) | 150 | logger.error('Cannot get params from URL.', err) |
@@ -153,7 +153,47 @@ export class PlayerManagerOptions { | |||
153 | 153 | ||
154 | // --------------------------------------------------------------------------- | 154 | // --------------------------------------------------------------------------- |
155 | 155 | ||
156 | async getPlayerOptions (options: { | 156 | getPlayerConstructorOptions (options: { |
157 | serverConfig: HTMLServerConfig | ||
158 | authorizationHeader: () => string | ||
159 | }): PeerTubePlayerContructorOptions { | ||
160 | const { serverConfig, authorizationHeader } = options | ||
161 | |||
162 | return { | ||
163 | controls: this.controls, | ||
164 | controlBar: this.controlBar, | ||
165 | |||
166 | muted: this.muted, | ||
167 | loop: this.loop, | ||
168 | |||
169 | playbackRate: this.playbackRate, | ||
170 | |||
171 | inactivityTimeout: 2500, | ||
172 | videoViewIntervalMs: 5000, | ||
173 | metricsUrl: window.location.origin + '/api/v1/metrics/playback', | ||
174 | |||
175 | authorizationHeader, | ||
176 | |||
177 | playerElement: () => this.playerHTML.getPlayerElement(), | ||
178 | enableHotkeys: true, | ||
179 | |||
180 | peertubeLink: () => this.peertubeLink, | ||
181 | instanceName: serverConfig.instance.name, | ||
182 | |||
183 | theaterButton: false, | ||
184 | |||
185 | serverUrl: window.location.origin, | ||
186 | language: navigator.language, | ||
187 | |||
188 | pluginsManager: this.peertubePlugin.getPluginsManager(), | ||
189 | |||
190 | errorNotifier: () => { | ||
191 | // Empty, we don't have a notifier in the embed | ||
192 | } | ||
193 | } | ||
194 | } | ||
195 | |||
196 | async getPlayerLoadOptions (options: { | ||
157 | video: VideoDetails | 197 | video: VideoDetails |
158 | captionsResponse: Response | 198 | captionsResponse: Response |
159 | 199 | ||
@@ -161,39 +201,35 @@ export class PlayerManagerOptions { | |||
161 | 201 | ||
162 | live?: LiveVideo | 202 | live?: LiveVideo |
163 | 203 | ||
204 | alreadyPlayed: boolean | ||
164 | forceAutoplay: boolean | 205 | forceAutoplay: boolean |
165 | 206 | ||
166 | authorizationHeader: () => string | ||
167 | videoFileToken: () => string | 207 | videoFileToken: () => string |
168 | 208 | ||
169 | videoPassword: () => string | 209 | videoPassword: () => string |
170 | requiresPassword: boolean | 210 | requiresPassword: boolean |
171 | 211 | ||
172 | serverConfig: HTMLServerConfig | ||
173 | |||
174 | autoplayFromPreviousVideo: boolean | ||
175 | |||
176 | translations: Translations | 212 | translations: Translations |
177 | 213 | ||
178 | playlistTracker?: PlaylistTracker | 214 | playlist?: { |
179 | playNextPlaylistVideo?: () => any | 215 | playlistTracker: PlaylistTracker |
180 | playPreviousPlaylistVideo?: () => any | 216 | playNext: () => any |
181 | onVideoUpdate?: (uuid: string) => any | 217 | playPrevious: () => any |
182 | }) { | 218 | onVideoUpdate: (uuid: string) => any |
219 | } | ||
220 | }): Promise<PeerTubePlayerLoadOptions> { | ||
183 | const { | 221 | const { |
184 | video, | 222 | video, |
185 | captionsResponse, | 223 | captionsResponse, |
186 | autoplayFromPreviousVideo, | ||
187 | videoFileToken, | 224 | videoFileToken, |
188 | videoPassword, | 225 | videoPassword, |
189 | requiresPassword, | 226 | requiresPassword, |
190 | translations, | 227 | translations, |
228 | alreadyPlayed, | ||
191 | forceAutoplay, | 229 | forceAutoplay, |
192 | playlistTracker, | 230 | playlist, |
193 | live, | 231 | live, |
194 | storyboardsResponse, | 232 | storyboardsResponse |
195 | authorizationHeader, | ||
196 | serverConfig | ||
197 | } = options | 233 | } = options |
198 | 234 | ||
199 | const [ videoCaptions, storyboard ] = await Promise.all([ | 235 | const [ videoCaptions, storyboard ] = await Promise.all([ |
@@ -201,88 +237,56 @@ export class PlayerManagerOptions { | |||
201 | this.buildStoryboard(storyboardsResponse) | 237 | this.buildStoryboard(storyboardsResponse) |
202 | ]) | 238 | ]) |
203 | 239 | ||
204 | const playerOptions: PeertubePlayerManagerOptions = { | 240 | return { |
205 | common: { | 241 | mode: this.mode, |
206 | // Autoplay in playlist mode | ||
207 | autoplay: autoplayFromPreviousVideo ? true : this.autoplay, | ||
208 | forceAutoplay, | ||
209 | |||
210 | controls: this.controls, | ||
211 | controlBar: this.controlBar, | ||
212 | |||
213 | muted: this.muted, | ||
214 | loop: this.loop, | ||
215 | |||
216 | p2pEnabled: this.p2pEnabled, | ||
217 | |||
218 | captions: videoCaptions.length !== 0, | ||
219 | subtitle: this.subtitle, | ||
220 | 242 | ||
221 | storyboard, | 243 | autoplay: forceAutoplay || alreadyPlayed || this.autoplay, |
244 | forceAutoplay, | ||
222 | 245 | ||
223 | startTime: playlistTracker | 246 | p2pEnabled: this.p2pEnabled, |
224 | ? playlistTracker.getCurrentElement().startTimestamp | ||
225 | : this.startTime, | ||
226 | stopTime: playlistTracker | ||
227 | ? playlistTracker.getCurrentElement().stopTimestamp | ||
228 | : this.stopTime, | ||
229 | 247 | ||
230 | playbackRate: this.playbackRate, | 248 | subtitle: this.subtitle, |
231 | 249 | ||
232 | videoCaptions, | 250 | storyboard, |
233 | inactivityTimeout: 2500, | ||
234 | videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), | ||
235 | videoViewIntervalMs: 5000, | ||
236 | metricsUrl: window.location.origin + '/api/v1/metrics/playback', | ||
237 | 251 | ||
238 | videoShortUUID: video.shortUUID, | 252 | startTime: playlist |
239 | videoUUID: video.uuid, | 253 | ? playlist.playlistTracker.getCurrentElement().startTimestamp |
254 | : this.startTime, | ||
255 | stopTime: playlist | ||
256 | ? playlist.playlistTracker.getCurrentElement().stopTimestamp | ||
257 | : this.stopTime, | ||
240 | 258 | ||
241 | playerElement: this.playerHTML.getPlayerElement(), | 259 | videoCaptions, |
242 | onPlayerElementChange: (element: HTMLVideoElement) => { | 260 | videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), |
243 | this.playerHTML.setPlayerElement(element) | ||
244 | }, | ||
245 | 261 | ||
246 | videoDuration: video.duration, | 262 | videoShortUUID: video.shortUUID, |
247 | enableHotkeys: true, | 263 | videoUUID: video.uuid, |
248 | 264 | ||
249 | peertubeLink: this.peertubeLink, | 265 | duration: video.duration, |
250 | instanceName: serverConfig.instance.name, | ||
251 | 266 | ||
252 | poster: window.location.origin + video.previewPath, | 267 | poster: window.location.origin + video.previewPath, |
253 | theaterButton: false, | ||
254 | 268 | ||
255 | serverUrl: window.location.origin, | 269 | embedUrl: window.location.origin + video.embedPath, |
256 | language: navigator.language, | 270 | embedTitle: video.name, |
257 | embedUrl: window.location.origin + video.embedPath, | ||
258 | embedTitle: video.name, | ||
259 | 271 | ||
260 | requiresUserAuth: videoRequiresUserAuth(video), | 272 | requiresUserAuth: videoRequiresUserAuth(video), |
261 | authorizationHeader, | 273 | videoFileToken, |
262 | videoFileToken, | ||
263 | 274 | ||
264 | requiresPassword, | 275 | requiresPassword, |
265 | videoPassword, | 276 | videoPassword, |
266 | 277 | ||
267 | errorNotifier: () => { | 278 | ...this.buildLiveOptions(video, live), |
268 | // Empty, we don't have a notifier in the embed | ||
269 | }, | ||
270 | 279 | ||
271 | ...this.buildLiveOptions(video, live), | 280 | ...this.buildPlaylistOptions(playlist), |
272 | 281 | ||
273 | ...this.buildPlaylistOptions(options) | 282 | dock: this.buildDockOptions(video), |
274 | }, | ||
275 | 283 | ||
276 | webtorrent: { | 284 | webVideo: { |
277 | videoFiles: video.files | 285 | videoFiles: video.files |
278 | }, | 286 | }, |
279 | 287 | ||
280 | ...this.buildP2PMediaLoaderOptions(video), | 288 | hls: this.buildHLSOptions(video) |
281 | |||
282 | pluginsManager: this.peertubePlugin.getPluginsManager() | ||
283 | } | 289 | } |
284 | |||
285 | return playerOptions | ||
286 | } | 290 | } |
287 | 291 | ||
288 | private buildLiveOptions (video: VideoDetails, live: LiveVideo) { | 292 | private buildLiveOptions (video: VideoDetails, live: LiveVideo) { |
@@ -308,15 +312,27 @@ export class PlayerManagerOptions { | |||
308 | } | 312 | } |
309 | } | 313 | } |
310 | 314 | ||
311 | private buildPlaylistOptions (options: { | 315 | private buildPlaylistOptions (options?: { |
312 | playlistTracker?: PlaylistTracker | 316 | playlistTracker: PlaylistTracker |
313 | playNextPlaylistVideo?: () => any | 317 | playNext: () => any |
314 | playPreviousPlaylistVideo?: () => any | 318 | playPrevious: () => any |
315 | onVideoUpdate?: (uuid: string) => any | 319 | onVideoUpdate: (uuid: string) => any |
316 | }) { | 320 | }) { |
317 | const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options | 321 | if (!options) { |
322 | return { | ||
323 | nextVideo: { | ||
324 | enabled: false, | ||
325 | displayControlBarButton: false, | ||
326 | getVideoTitle: () => '' | ||
327 | }, | ||
328 | previousVideo: { | ||
329 | enabled: false, | ||
330 | displayControlBarButton: false | ||
331 | } | ||
332 | } | ||
333 | } | ||
318 | 334 | ||
319 | if (!playlistTracker) return {} | 335 | const { playlistTracker, playNext, playPrevious, onVideoUpdate } = options |
320 | 336 | ||
321 | return { | 337 | return { |
322 | playlist: { | 338 | playlist: { |
@@ -332,27 +348,37 @@ export class PlayerManagerOptions { | |||
332 | } | 348 | } |
333 | }, | 349 | }, |
334 | 350 | ||
335 | nextVideo: () => playNextPlaylistVideo(), | 351 | previousVideo: { |
336 | hasNextVideo: () => playlistTracker.hasNextPlaylistElement(), | 352 | enabled: playlistTracker.hasPreviousPlaylistElement(), |
353 | handler: () => playPrevious(), | ||
354 | displayControlBarButton: true | ||
355 | }, | ||
356 | |||
357 | nextVideo: { | ||
358 | enabled: playlistTracker.hasNextPlaylistElement(), | ||
359 | handler: () => playNext(), | ||
360 | getVideoTitle: () => playlistTracker.getNextPlaylistElement()?.video?.name, | ||
361 | displayControlBarButton: true | ||
362 | }, | ||
337 | 363 | ||
338 | previousVideo: () => playPreviousPlaylistVideo(), | 364 | upnext: { |
339 | hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement() | 365 | isEnabled: () => true, |
366 | isSuspended: () => false, | ||
367 | timeout: 0 | ||
368 | } | ||
340 | } | 369 | } |
341 | } | 370 | } |
342 | 371 | ||
343 | private buildP2PMediaLoaderOptions (video: VideoDetails) { | 372 | private buildHLSOptions (video: VideoDetails): HLSOptions { |
344 | if (this.mode !== 'p2p-media-loader') return {} | ||
345 | |||
346 | const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) | 373 | const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) |
374 | if (!hlsPlaylist) return undefined | ||
347 | 375 | ||
348 | return { | 376 | return { |
349 | p2pMediaLoader: { | 377 | playlistUrl: hlsPlaylist.playlistUrl, |
350 | playlistUrl: hlsPlaylist.playlistUrl, | 378 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, |
351 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, | 379 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), |
352 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), | 380 | trackerAnnounce: video.trackerUrls, |
353 | trackerAnnounce: video.trackerUrls, | 381 | videoFiles: hlsPlaylist.files |
354 | videoFiles: hlsPlaylist.files | ||
355 | } as P2PMediaLoaderOptions | ||
356 | } | 382 | } |
357 | } | 383 | } |
358 | 384 | ||
@@ -374,6 +400,35 @@ export class PlayerManagerOptions { | |||
374 | 400 | ||
375 | // --------------------------------------------------------------------------- | 401 | // --------------------------------------------------------------------------- |
376 | 402 | ||
403 | private buildDockOptions (videoInfo: VideoDetails) { | ||
404 | if (!this.hasControls()) return undefined | ||
405 | |||
406 | const title = this.hasTitle() | ||
407 | ? videoInfo.name | ||
408 | : undefined | ||
409 | |||
410 | const description = this.hasWarningTitle() && this.hasP2PEnabled() | ||
411 | ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>' | ||
412 | : undefined | ||
413 | |||
414 | if (!title && !description) return | ||
415 | |||
416 | const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50) | ||
417 | const avatar = availableAvatars.length !== 0 | ||
418 | ? availableAvatars[0] | ||
419 | : undefined | ||
420 | |||
421 | return { | ||
422 | title, | ||
423 | description, | ||
424 | avatarUrl: title && avatar | ||
425 | ? avatar.path | ||
426 | : undefined | ||
427 | } | ||
428 | } | ||
429 | |||
430 | // --------------------------------------------------------------------------- | ||
431 | |||
377 | private isP2PEnabled (config: HTMLServerConfig, video: Video) { | 432 | private isP2PEnabled (config: HTMLServerConfig, video: Video) { |
378 | const userP2PEnabled = getBoolOrDefault( | 433 | const userP2PEnabled = getBoolOrDefault( |
379 | peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), | 434 | peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), |
diff --git a/client/src/standalone/videos/test-embed.ts b/client/src/standalone/videos/test-embed.ts index b34df11ee..b7a283c4d 100644 --- a/client/src/standalone/videos/test-embed.ts +++ b/client/src/standalone/videos/test-embed.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import './test-embed.scss' | 1 | import './test-embed.scss' |
2 | import { PeerTubeResolution, PlayerEventType } from '../player/definitions' | 2 | import { PeerTubeResolution, PlayerEventType } from '../embed-player-api/definitions' |
3 | import { PeerTubePlayer } from '../player/player' | 3 | import { PeerTubePlayer } from '../embed-player-api/player' |
4 | import { logger } from '../../root-helpers' | 4 | import { logger } from '../../root-helpers' |
5 | 5 | ||
6 | window.addEventListener('load', async () => { | 6 | window.addEventListener('load', async () => { |