aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts7
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html10
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts14
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts32
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.ts8
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts14
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.ts14
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.ts8
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts26
-rw-r--r--client/src/app/+admin/overview/videos/video-admin.service.ts14
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html12
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts52
-rw-r--r--client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts9
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts2
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts2
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts14
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html9
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts26
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts12
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts20
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html6
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts14
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts10
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html2
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html3
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.html4
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts8
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts16
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html9
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts546
-rw-r--r--client/src/app/app.module.ts10
-rw-r--r--client/src/app/core/confirm/confirm.service.ts12
-rw-r--r--client/src/app/core/users/user.model.ts2
-rw-r--r--client/src/app/helpers/i18n-utils.ts69
-rw-r--r--client/src/app/helpers/utils/object.ts2
-rw-r--r--client/src/app/modal/confirm.component.html6
-rw-r--r--client/src/app/modal/confirm.component.ts9
-rw-r--r--client/src/app/shared/form-validators/custom-config-validators.ts17
-rw-r--r--client/src/app/shared/form-validators/video-validators.ts9
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts8
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.ts14
-rw-r--r--client/src/app/shared/shared-main/angular/from-now.pipe.ts17
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts3
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts8
-rw-r--r--client/src/app/shared/shared-main/video/index.ts1
-rw-r--r--client/src/app/shared/shared-main/video/video-edit.model.ts8
-rw-r--r--client/src/app/shared/shared-main/video/video-file-token.service.ts11
-rw-r--r--client/src/app/shared/shared-main/video/video-password.service.ts29
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts20
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts85
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.ts14
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.ts8
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.html4
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.ts4
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.service.ts25
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts12
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.ts15
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts4
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts1
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html3
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts4
-rw-r--r--client/src/assets/player/index.ts2
-rw-r--r--client/src/assets/player/peertube-player-manager.ts266
-rw-r--r--client/src/assets/player/peertube-player.ts522
-rw-r--r--client/src/assets/player/shared/bezels/bezels-plugin.ts4
-rw-r--r--client/src/assets/player/shared/bezels/pause-bezel.ts49
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts2
-rw-r--r--client/src/assets/player/shared/control-bar/next-previous-video-button.ts25
-rw-r--r--client/src/assets/player/shared/control-bar/p2p-info-button.ts103
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-link-button.ts47
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-live-display.ts8
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts33
-rw-r--r--client/src/assets/player/shared/control-bar/storyboard-plugin.ts197
-rw-r--r--client/src/assets/player/shared/control-bar/theater-button.ts17
-rw-r--r--client/src/assets/player/shared/dock/peertube-dock-component.ts31
-rw-r--r--client/src/assets/player/shared/dock/peertube-dock-plugin.ts19
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts2
-rw-r--r--client/src/assets/player/shared/manager-options/control-bar-options-builder.ts155
-rw-r--r--client/src/assets/player/shared/manager-options/index.ts1
-rw-r--r--client/src/assets/player/shared/manager-options/manager-options-builder.ts186
-rw-r--r--client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts47
-rw-r--r--client/src/assets/player/shared/metrics/metrics-plugin.ts80
-rw-r--r--client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts26
-rw-r--r--client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts50
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts89
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts60
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/segment-validator.ts127
-rw-r--r--client/src/assets/player/shared/peertube/peertube-plugin.ts248
-rw-r--r--client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts136
-rw-r--r--client/src/assets/player/shared/player-options-builder/hls-options-builder.ts (renamed from client/src/assets/player/shared/manager-options/hls-options-builder.ts)85
-rw-r--r--client/src/assets/player/shared/player-options-builder/index.ts3
-rw-r--r--client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts22
-rw-r--r--client/src/assets/player/shared/playlist/playlist-button.ts22
-rw-r--r--client/src/assets/player/shared/playlist/playlist-menu-item.ts33
-rw-r--r--client/src/assets/player/shared/playlist/playlist-menu.ts73
-rw-r--r--client/src/assets/player/shared/playlist/playlist-plugin.ts19
-rw-r--r--client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts37
-rw-r--r--client/src/assets/player/shared/settings/resolution-menu-button.ts77
-rw-r--r--client/src/assets/player/shared/settings/resolution-menu-item.ts38
-rw-r--r--client/src/assets/player/shared/settings/settings-dialog.ts12
-rw-r--r--client/src/assets/player/shared/settings/settings-menu-button.ts12
-rw-r--r--client/src/assets/player/shared/settings/settings-menu-item.ts86
-rw-r--r--client/src/assets/player/shared/stats/stats-card.ts45
-rw-r--r--client/src/assets/player/shared/stats/stats-plugin.ts16
-rw-r--r--client/src/assets/player/shared/upnext/end-card.ts46
-rw-r--r--client/src/assets/player/shared/upnext/upnext-plugin.ts24
-rw-r--r--client/src/assets/player/shared/web-video/web-video-plugin.ts186
-rw-r--r--client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts234
-rw-r--r--client/src/assets/player/shared/webtorrent/video-renderer.ts134
-rw-r--r--client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts663
-rw-r--r--client/src/assets/player/types/index.ts2
-rw-r--r--client/src/assets/player/types/manager-options.ts98
-rw-r--r--client/src/assets/player/types/peertube-player-options.ts117
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts144
-rw-r--r--client/src/root-helpers/video.ts13
-rw-r--r--client/src/sass/player/control-bar.scss11
-rw-r--r--client/src/sass/player/index.scss1
-rw-r--r--client/src/sass/player/mobile.scss28
-rw-r--r--client/src/sass/player/peertube-skin.scss4
-rw-r--r--client/src/sass/player/settings-menu.scss9
-rw-r--r--client/src/sass/player/storyboard.scss26
-rw-r--r--client/src/shims/http.ts1
-rw-r--r--client/src/shims/https.ts1
-rw-r--r--client/src/shims/stream.ts1
-rw-r--r--client/src/standalone/embed-player-api/.npmignore (renamed from client/src/standalone/player/.npmignore)0
-rw-r--r--client/src/standalone/embed-player-api/README.md (renamed from client/src/standalone/player/README.md)0
-rw-r--r--client/src/standalone/embed-player-api/definitions.ts (renamed from client/src/standalone/player/definitions.ts)0
-rw-r--r--client/src/standalone/embed-player-api/events.ts (renamed from client/src/standalone/player/events.ts)0
-rw-r--r--client/src/standalone/embed-player-api/package.json (renamed from client/src/standalone/player/package.json)0
-rw-r--r--client/src/standalone/embed-player-api/player.ts (renamed from client/src/standalone/player/player.ts)0
-rw-r--r--client/src/standalone/embed-player-api/tsconfig.json (renamed from client/src/standalone/player/tsconfig.json)0
-rw-r--r--client/src/standalone/embed-player-api/webpack.config.js (renamed from client/src/standalone/player/webpack.config.js)0
-rw-r--r--client/src/standalone/videos/embed-api.ts19
-rw-r--r--client/src/standalone/videos/embed.html19
-rw-r--r--client/src/standalone/videos/embed.scss43
-rw-r--r--client/src/standalone/videos/embed.ts276
-rw-r--r--client/src/standalone/videos/shared/auth-http.ts10
-rw-r--r--client/src/standalone/videos/shared/index.ts2
-rw-r--r--client/src/standalone/videos/shared/player-html.ts59
-rw-r--r--client/src/standalone/videos/shared/player-options-builder.ts (renamed from client/src/standalone/videos/shared/player-manager-options.ts)281
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts35
-rw-r--r--client/src/standalone/videos/test-embed.ts4
-rw-r--r--client/src/types/index.ts1
-rw-r--r--client/src/types/server-error.model.ts11
152 files changed, 3656 insertions, 3360 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
index bbf946df0..9701e7f85 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
@@ -52,6 +52,20 @@
52 52
53 <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div> 53 <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div>
54 </div> 54 </div>
55
56 <div class="form-group" formGroupName="torrents">
57 <label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
58
59 <div class="number-with-unit">
60 <input
61 type="number" min="0" id="cacheStoryboardsSize" class="form-control"
62 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
63 >
64 <span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
65 </div>
66
67 <div *ngIf="formErrors.cache.storyboards.size" class="form-error">{{ formErrors.cache.storyboards.size }}</div>
68 </div>
55 </ng-container> 69 </ng-container>
56 70
57 </div> 71 </div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
index 79a98f288..06c5e6221 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
@@ -10,7 +10,7 @@ export class EditAdvancedConfigurationComponent {
10 @Input() form: FormGroup 10 @Input() form: FormGroup
11 @Input() formErrors: any 11 @Input() formErrors: any
12 12
13 getCacheSize (type: 'captions' | 'previews' | 'torrents') { 13 getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
14 return this.form.value['cache'][type]['size'] 14 return this.form.value['cache'][type]['size']
15 } 15 }
16} 16}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
index 628c2d102..42c0e6dc2 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
@@ -1,6 +1,6 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { FormGroup } from '@angular/forms' 2import { FormGroup } from '@angular/forms'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4 4
5export type ResolutionOption = { 5export type ResolutionOption = {
6 id: string 6 id: string
@@ -99,10 +99,7 @@ export class EditConfigurationService {
99 return { 99 return {
100 value, 100 value,
101 atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible 101 atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
102 unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)( 102 unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value })
103 { value },
104 $localize`threads`
105 )
106 } 103 }
107 } 104 }
108} 105}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 2c3b7560d..b381473d6 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -9,8 +9,7 @@ import { Notifier } from '@app/core'
9import { ServerService } from '@app/core/server/server.service' 9import { ServerService } from '@app/core/server/server.service'
10import { 10import {
11 ADMIN_EMAIL_VALIDATOR, 11 ADMIN_EMAIL_VALIDATOR,
12 CACHE_CAPTIONS_SIZE_VALIDATOR, 12 CACHE_SIZE_VALIDATOR,
13 CACHE_PREVIEWS_SIZE_VALIDATOR,
14 CONCURRENCY_VALIDATOR, 13 CONCURRENCY_VALIDATOR,
15 INDEX_URL_VALIDATOR, 14 INDEX_URL_VALIDATOR,
16 INSTANCE_NAME_VALIDATOR, 15 INSTANCE_NAME_VALIDATOR,
@@ -120,13 +119,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
120 }, 119 },
121 cache: { 120 cache: {
122 previews: { 121 previews: {
123 size: CACHE_PREVIEWS_SIZE_VALIDATOR 122 size: CACHE_SIZE_VALIDATOR
124 }, 123 },
125 captions: { 124 captions: {
126 size: CACHE_CAPTIONS_SIZE_VALIDATOR 125 size: CACHE_SIZE_VALIDATOR
127 }, 126 },
128 torrents: { 127 torrents: {
129 size: CACHE_CAPTIONS_SIZE_VALIDATOR 128 size: CACHE_SIZE_VALIDATOR
129 },
130 storyboards: {
131 size: CACHE_SIZE_VALIDATOR
130 } 132 }
131 }, 133 },
132 signup: { 134 signup: {
@@ -188,7 +190,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
188 hls: { 190 hls: {
189 enabled: null 191 enabled: null
190 }, 192 },
191 webtorrent: { 193 webVideos: {
192 enabled: null 194 enabled: null
193 }, 195 },
194 remoteRunners: { 196 remoteRunners: {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
index fb750aca6..accf2c28c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
@@ -67,11 +67,11 @@
67 <div class="callout callout-light pt-2 mt-2 pb-0"> 67 <div class="callout callout-light pt-2 mt-2 pb-0">
68 <h3 class="callout-title" i18n>Output formats</h3> 68 <h3 class="callout-title" i18n>Output formats</h3>
69 69
70 <ng-container formGroupName="webtorrent"> 70 <ng-container formGroupName="webVideos">
71 <div class="form-group" [ngClass]="getTranscodingDisabledClass()"> 71 <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
72 <my-peertube-checkbox 72 <my-peertube-checkbox
73 inputName="transcodingWebTorrentEnabled" formControlName="enabled" 73 inputName="transcodingWebVideosEnabled" formControlName="enabled"
74 i18n-labelText labelText="WebTorrent enabled" 74 i18n-labelText labelText="Web Videos enabled"
75 > 75 >
76 <ng-template ptTemplate="help"> 76 <ng-template ptTemplate="help">
77 <ng-container> 77 <ng-container>
@@ -93,14 +93,14 @@
93 <ng-container i18n> 93 <ng-container i18n>
94 <strong>Requires ffmpeg >= 4.1</strong> 94 <strong>Requires ffmpeg >= 4.1</strong>
95 95
96 <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with plain WebTorrent:</p> 96 <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with Web Videos:</p>
97 <ul> 97 <ul>
98 <li>Resolution change is smoother</li> 98 <li>Resolution change is smoother</li>
99 <li>Faster playback especially with long videos</li> 99 <li>Faster playback especially with long videos</li>
100 <li>More stable playback (less bugs/infinite loading)</li> 100 <li>More stable playback (less bugs/infinite loading)</li>
101 </ul> 101 </ul>
102 102
103 <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p> 103 <p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
104 </ng-container> 104 </ng-container>
105 </ng-template> 105 </ng-template>
106 </my-peertube-checkbox> 106 </my-peertube-checkbox>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
index c5f4ecddb..6496e8753 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
@@ -90,9 +90,9 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
90 const transcodingControl = this.form.get('transcoding.enabled') 90 const transcodingControl = this.form.get('transcoding.enabled')
91 const videoStudioControl = this.form.get('videoStudio.enabled') 91 const videoStudioControl = this.form.get('videoStudio.enabled')
92 const hlsControl = this.form.get('transcoding.hls.enabled') 92 const hlsControl = this.form.get('transcoding.hls.enabled')
93 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled') 93 const webVideosControl = this.form.get('transcoding.webVideos.enabled')
94 94
95 webtorrentControl.valueChanges 95 webVideosControl.valueChanges
96 .subscribe(newValue => { 96 .subscribe(newValue => {
97 if (newValue === false && !hlsControl.disabled) { 97 if (newValue === false && !hlsControl.disabled) {
98 hlsControl.disable() 98 hlsControl.disable()
@@ -105,12 +105,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
105 105
106 hlsControl.valueChanges 106 hlsControl.valueChanges
107 .subscribe(newValue => { 107 .subscribe(newValue => {
108 if (newValue === false && !webtorrentControl.disabled) { 108 if (newValue === false && !webVideosControl.disabled) {
109 webtorrentControl.disable() 109 webVideosControl.disable()
110 } 110 }
111 111
112 if (newValue === true && !webtorrentControl.enabled) { 112 if (newValue === true && !webVideosControl.enabled) {
113 webtorrentControl.enable() 113 webVideosControl.enable()
114 } 114 }
115 }) 115 })
116 116
@@ -122,7 +122,7 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
122 }) 122 })
123 123
124 transcodingControl.updateValueAndValidity() 124 transcodingControl.updateValueAndValidity()
125 webtorrentControl.updateValueAndValidity() 125 webVideosControl.updateValueAndValidity()
126 videoStudioControl.updateValueAndValidity() 126 videoStudioControl.updateValueAndValidity()
127 hlsControl.updateValueAndValidity() 127 hlsControl.updateValueAndValidity()
128 } 128 }
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index cebb2e1a2..618892242 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -1,7 +1,7 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { Component, OnInit } from '@angular/core' 2import { Component, OnInit } from '@angular/core'
3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { AdvancedInputFilter } from '@app/shared/shared-forms' 5import { AdvancedInputFilter } from '@app/shared/shared-forms'
6import { InstanceFollowService } from '@app/shared/shared-instance' 6import { InstanceFollowService } from '@app/shared/shared-instance'
7import { DropdownAction } from '@app/shared/shared-main' 7import { DropdownAction } from '@app/shared/shared-main'
@@ -63,9 +63,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
63 .subscribe({ 63 .subscribe({
64 next: () => { 64 next: () => {
65 // eslint-disable-next-line max-len 65 // eslint-disable-next-line max-len
66 const message = prepareIcu($localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 66 const message = formatICU(
67 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 67 $localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
68 $localize`Follow requests accepted` 68 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
69 ) 69 )
70 this.notifier.success(message) 70 this.notifier.success(message)
71 71
@@ -78,9 +78,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
78 78
79 async rejectFollower (follows: ActorFollow[]) { 79 async rejectFollower (follows: ActorFollow[]) {
80 // eslint-disable-next-line max-len 80 // eslint-disable-next-line max-len
81 const message = prepareIcu($localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( 81 const message = formatICU(
82 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 82 $localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`,
83 $localize`Do you really want to reject these follow requests?` 83 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
84 ) 84 )
85 85
86 const res = await this.confirmService.confirm(message, $localize`Reject`) 86 const res = await this.confirmService.confirm(message, $localize`Reject`)
@@ -90,9 +90,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
90 .subscribe({ 90 .subscribe({
91 next: () => { 91 next: () => {
92 // eslint-disable-next-line max-len 92 // eslint-disable-next-line max-len
93 const message = prepareIcu($localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 93 const message = formatICU(
94 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 94 $localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
95 $localize`Follow requests rejected` 95 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
96 ) 96 )
97 this.notifier.success(message) 97 this.notifier.success(message)
98 98
@@ -110,9 +110,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
110 message += '<br /><br />' 110 message += '<br /><br />'
111 111
112 // eslint-disable-next-line max-len 112 // eslint-disable-next-line max-len
113 message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( 113 message += formatICU(
114 icuParams, 114 $localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`,
115 $localize`Do you really want to delete these follow requests?` 115 icuParams
116 ) 116 )
117 117
118 const res = await this.confirmService.confirm(message, $localize`Delete`) 118 const res = await this.confirmService.confirm(message, $localize`Delete`)
@@ -122,9 +122,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
122 .subscribe({ 122 .subscribe({
123 next: () => { 123 next: () => {
124 // eslint-disable-next-line max-len 124 // eslint-disable-next-line max-len
125 const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 125 const message = formatICU(
126 icuParams, 126 $localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
127 $localize`Follow requests removed` 127 icuParams
128 ) 128 )
129 129
130 this.notifier.success(message) 130 this.notifier.success(message)
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
index 8f74e82a6..54b3cebc5 100644
--- a/client/src/app/+admin/follows/following-list/follow-modal.component.ts
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' 4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { InstanceFollowService } from '@app/shared/shared-instance' 6import { InstanceFollowService } from '@app/shared/shared-instance'
@@ -62,9 +62,9 @@ export class FollowModalComponent extends FormReactive implements OnInit {
62 .subscribe({ 62 .subscribe({
63 next: () => { 63 next: () => {
64 this.notifier.success( 64 this.notifier.success(
65 prepareIcu($localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`)( 65 formatICU(
66 { count: hostsOrHandles.length }, 66 $localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`,
67 $localize`Follow request(s) sent!` 67 { count: hostsOrHandles.length }
68 ) 68 )
69 ) 69 )
70 70
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index 71f2fbe66..6c8723c16 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -6,7 +6,7 @@ import { InstanceFollowService } from '@app/shared/shared-instance'
6import { ActorFollow } from '@shared/models' 6import { ActorFollow } from '@shared/models'
7import { FollowModalComponent } from './follow-modal.component' 7import { FollowModalComponent } from './follow-modal.component'
8import { DropdownAction } from '@app/shared/shared-main' 8import { DropdownAction } from '@app/shared/shared-main'
9import { prepareIcu } from '@app/helpers' 9import { formatICU } from '@app/helpers'
10 10
11@Component({ 11@Component({
12 templateUrl: './following-list.component.html', 12 templateUrl: './following-list.component.html',
@@ -64,9 +64,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
64 async removeFollowing (follows: ActorFollow[]) { 64 async removeFollowing (follows: ActorFollow[]) {
65 const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) } 65 const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
66 66
67 const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)( 67 const message = formatICU(
68 icuParams, 68 $localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`,
69 $localize`Do you really want to unfollow these entries?` 69 icuParams
70 ) 70 )
71 71
72 const res = await this.confirmService.confirm(message, $localize`Unfollow`) 72 const res = await this.confirmService.confirm(message, $localize`Unfollow`)
@@ -76,9 +76,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
76 .subscribe({ 76 .subscribe({
77 next: () => { 77 next: () => {
78 // eslint-disable-next-line max-len 78 // eslint-disable-next-line max-len
79 const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)( 79 const message = formatICU(
80 icuParams, 80 $localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`,
81 $localize`You are not following them anymore.` 81 icuParams
82 ) 82 )
83 83
84 this.notifier.success(message) 84 this.notifier.success(message)
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
index 3ca1ceab8..35d9d13d7 100644
--- a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { prepareIcu } from '@app/helpers' 5import { formatICU } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { DropdownAction } from '@app/shared/shared-main' 7import { DropdownAction } from '@app/shared/shared-main'
8import { UserRegistration, UserRegistrationState } from '@shared/models' 8import { UserRegistration, UserRegistrationState } from '@shared/models'
@@ -121,9 +121,9 @@ export class RegistrationListComponent extends RestTable <UserRegistration> impl
121 const icuParams = { count: registrations.length, username: registrations[0].username } 121 const icuParams = { count: registrations.length, username: registrations[0].username }
122 122
123 // eslint-disable-next-line max-len 123 // eslint-disable-next-line max-len
124 const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)( 124 const message = formatICU(
125 icuParams, 125 $localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`,
126 $localize`Do you really want to delete these registration requests?` 126 icuParams
127 ) 127 )
128 128
129 const res = await this.confirmService.confirm(message, $localize`Delete`) 129 const res = await this.confirmService.confirm(message, $localize`Delete`)
@@ -133,9 +133,9 @@ export class RegistrationListComponent extends RestTable <UserRegistration> impl
133 .subscribe({ 133 .subscribe({
134 next: () => { 134 next: () => {
135 // eslint-disable-next-line max-len 135 // eslint-disable-next-line max-len
136 const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)( 136 const message = formatICU(
137 icuParams, 137 $localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`,
138 $localize`Registration requests removed` 138 icuParams
139 ) 139 )
140 140
141 this.notifier.success(message) 141 this.notifier.success(message)
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
index 28efdc076..b77072665 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
@@ -7,7 +7,7 @@ import { DropdownAction } from '@app/shared/shared-main'
7import { BulkService } from '@app/shared/shared-moderation' 7import { BulkService } from '@app/shared/shared-moderation'
8import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment' 8import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment'
9import { FeedFormat, UserRight } from '@shared/models' 9import { FeedFormat, UserRight } from '@shared/models'
10import { prepareIcu } from '@app/helpers' 10import { formatICU } from '@app/helpers'
11 11
12@Component({ 12@Component({
13 selector: 'my-video-comment-list', 13 selector: 'my-video-comment-list',
@@ -146,9 +146,9 @@ export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> imp
146 .subscribe({ 146 .subscribe({
147 next: () => { 147 next: () => {
148 this.notifier.success( 148 this.notifier.success(
149 prepareIcu($localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`)( 149 formatICU(
150 { count: commentArgs.length }, 150 $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`,
151 $localize`${commentArgs.length} comment(s) deleted.` 151 { count: commentArgs.length }
152 ) 152 )
153 ) 153 )
154 154
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index 19420b748..5d5abf6f4 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { getAPIHost, prepareIcu } from '@app/helpers' 5import { formatICU, getAPIHost } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { Actor, DropdownAction } from '@app/shared/shared-main' 7import { Actor, DropdownAction } from '@app/shared/shared-main'
8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation' 8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation'
@@ -210,9 +210,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
210 210
211 async unbanUsers (users: User[]) { 211 async unbanUsers (users: User[]) {
212 const res = await this.confirmService.confirm( 212 const res = await this.confirmService.confirm(
213 prepareIcu($localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`)( 213 formatICU(
214 { count: users.length }, 214 $localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`,
215 $localize`Do you really want to unban ${users.length} users?` 215 { count: users.length }
216 ), 216 ),
217 $localize`Unban` 217 $localize`Unban`
218 ) 218 )
@@ -223,9 +223,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
223 .subscribe({ 223 .subscribe({
224 next: () => { 224 next: () => {
225 this.notifier.success( 225 this.notifier.success(
226 prepareIcu($localize`{count, plural, =1 {1 user unbanned.} other {{count} users unbanned.}}`)( 226 formatICU(
227 { count: users.length }, 227 $localize`{count, plural, =1 {1 user unbanned.} other {{count} users unbanned.}}`,
228 $localize`${users.length} users unbanned.` 228 { count: users.length }
229 ) 229 )
230 ) 230 )
231 this.reloadData() 231 this.reloadData()
@@ -252,9 +252,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
252 .subscribe({ 252 .subscribe({
253 next: () => { 253 next: () => {
254 this.notifier.success( 254 this.notifier.success(
255 prepareIcu($localize`{count, plural, =1 {1 user deleted.} other {{count} users deleted.}}`)( 255 formatICU(
256 { count: users.length }, 256 $localize`{count, plural, =1 {1 user deleted.} other {{count} users deleted.}}`,
257 $localize`${users.length} users deleted.` 257 { count: users.length }
258 ) 258 )
259 ) 259 )
260 260
@@ -270,9 +270,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
270 .subscribe({ 270 .subscribe({
271 next: () => { 271 next: () => {
272 this.notifier.success( 272 this.notifier.success(
273 prepareIcu($localize`{count, plural, =1 {1 user email set as verified.} other {{count} user emails set as verified.}}`)( 273 formatICU(
274 { count: users.length }, 274 $localize`{count, plural, =1 {1 user email set as verified.} other {{count} user emails set as verified.}}`,
275 $localize`${users.length} users email set as verified.` 275 { count: users.length }
276 ) 276 )
277 ) 277 )
278 278
diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts
index 4b9357fb7..722495706 100644
--- a/client/src/app/+admin/overview/videos/video-admin.service.ts
+++ b/client/src/app/+admin/overview/videos/video-admin.service.ts
@@ -59,12 +59,12 @@ export class VideoAdminService {
59 title: $localize`Video files`, 59 title: $localize`Video files`,
60 children: [ 60 children: [
61 { 61 {
62 value: 'webtorrent:true isLocal:true', 62 value: 'webVideos:true isLocal:true',
63 label: $localize`With WebTorrent` 63 label: $localize`With Web Videos`
64 }, 64 },
65 { 65 {
66 value: 'webtorrent:false isLocal:true', 66 value: 'webVideos:false isLocal:true',
67 label: $localize`Without WebTorrent` 67 label: $localize`Without Web Videos`
68 }, 68 },
69 { 69 {
70 value: 'hls:true isLocal:true', 70 value: 'hls:true isLocal:true',
@@ -126,8 +126,8 @@ export class VideoAdminService {
126 prefix: 'hls:', 126 prefix: 'hls:',
127 isBoolean: true 127 isBoolean: true
128 }, 128 },
129 hasWebtorrentFiles: { 129 hasWebVideoFiles: {
130 prefix: 'webtorrent:', 130 prefix: 'webVideos:',
131 isBoolean: true 131 isBoolean: true
132 }, 132 },
133 isLive: { 133 isLive: {
@@ -151,7 +151,7 @@ export class VideoAdminService {
151 } 151 }
152 152
153 if (filters.excludePublic) { 153 if (filters.excludePublic) {
154 privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] 154 privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]
155 155
156 filters.excludePublic = undefined 156 filters.excludePublic = undefined
157 } 157 }
diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html
index c4f78cadc..3a4666435 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -83,8 +83,8 @@
83 </td> 83 </td>
84 84
85 <td> 85 <td>
86 <span *ngIf="isHLS(video)" class="pt-badge badge-blue">HLS</span> 86 <span *ngIf="hasHLS(video)" class="pt-badge badge-blue">HLS</span>
87 <span *ngIf="isWebTorrent(video)" class="pt-badge badge-blue">WebTorrent ({{ video.files.length }})</span> 87 <span *ngIf="hasWebVideos(video)" class="pt-badge badge-blue">Web Videos ({{ video.files.length }})</span>
88 <span i18n *ngIf="video.isLive" class="pt-badge badge-blue">Live</span> 88 <span i18n *ngIf="video.isLive" class="pt-badge badge-blue">Live</span>
89 <span i18n *ngIf="hasObjectStorage(video)" class="pt-badge badge-purple">Object storage</span> 89 <span i18n *ngIf="hasObjectStorage(video)" class="pt-badge badge-purple">Object storage</span>
90 90
@@ -102,8 +102,8 @@
102 <tr> 102 <tr>
103 <td class="video-info expand-cell" myAutoColspan> 103 <td class="video-info expand-cell" myAutoColspan>
104 <div> 104 <div>
105 <div *ngIf="isWebTorrent(video)"> 105 <div *ngIf="hasWebVideos(video)">
106 WebTorrent: 106 Web Videos:
107 107
108 <ul> 108 <ul>
109 <li *ngFor="let file of video.files"> 109 <li *ngFor="let file of video.files">
@@ -112,13 +112,13 @@
112 <my-global-icon 112 <my-global-icon
113 *ngIf="canRemoveOneFile(video)" 113 *ngIf="canRemoveOneFile(video)"
114 i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button" 114 i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button"
115 (click)="removeVideoFile(video, file, 'webtorrent')" 115 (click)="removeVideoFile(video, file, 'web-videos')"
116 ></my-global-icon> 116 ></my-global-icon>
117 </li> 117 </li>
118 </ul> 118 </ul>
119 </div> 119 </div>
120 120
121 <div *ngIf="isHLS(video)"> 121 <div *ngIf="hasHLS(video)">
122 HLS: 122 HLS:
123 123
124 <ul> 124 <ul>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts
index ebf82ce16..52f02d8d0 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -3,7 +3,7 @@ import { finalize } from 'rxjs/operators'
3import { Component, OnInit, ViewChild } from '@angular/core' 3import { Component, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 5import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
6import { prepareIcu } from '@app/helpers' 6import { formatICU } from '@app/helpers'
7import { AdvancedInputFilter } from '@app/shared/shared-forms' 7import { AdvancedInputFilter } from '@app/shared/shared-forms'
8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation' 9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation'
@@ -99,8 +99,8 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
99 iconName: 'cog' 99 iconName: 'cog'
100 }, 100 },
101 { 101 {
102 label: $localize`Run WebTorrent transcoding`, 102 label: $localize`Run Web Video transcoding`,
103 handler: videos => this.runTranscoding(videos, 'webtorrent'), 103 handler: videos => this.runTranscoding(videos, 'web-video'),
104 isDisplayed: videos => videos.every(v => v.canRunTranscoding(this.authUser)), 104 isDisplayed: videos => videos.every(v => v.canRunTranscoding(this.authUser)),
105 iconName: 'cog' 105 iconName: 'cog'
106 }, 106 },
@@ -111,8 +111,8 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
111 iconName: 'delete' 111 iconName: 'delete'
112 }, 112 },
113 { 113 {
114 label: $localize`Delete WebTorrent files`, 114 label: $localize`Delete Web Video files`,
115 handler: videos => this.removeVideoFiles(videos, 'webtorrent'), 115 handler: videos => this.removeVideoFiles(videos, 'web-videos'),
116 isDisplayed: videos => videos.every(v => v.canRemoveFiles(this.authUser)), 116 isDisplayed: videos => videos.every(v => v.canRemoveFiles(this.authUser)),
117 iconName: 'delete' 117 iconName: 'delete'
118 } 118 }
@@ -150,14 +150,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
150 return video.state.id === VideoState.TO_IMPORT 150 return video.state.id === VideoState.TO_IMPORT
151 } 151 }
152 152
153 isHLS (video: Video) { 153 hasHLS (video: Video) {
154 const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) 154 const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
155 if (!p) return false 155 if (!p) return false
156 156
157 return p.files.length !== 0 157 return p.files.length !== 0
158 } 158 }
159 159
160 isWebTorrent (video: Video) { 160 hasWebVideos (video: Video) {
161 return video.files.length !== 0 161 return video.files.length !== 0
162 } 162 }
163 163
@@ -176,14 +176,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
176 getFilesSize (video: Video) { 176 getFilesSize (video: Video) {
177 let files = video.files 177 let files = video.files
178 178
179 if (this.isHLS(video)) { 179 if (this.hasHLS(video)) {
180 files = files.concat(video.streamingPlaylists[0].files) 180 files = files.concat(video.streamingPlaylists[0].files)
181 } 181 }
182 182
183 return files.reduce((p, f) => p += f.size, 0) 183 return files.reduce((p, f) => p += f.size, 0)
184 } 184 }
185 185
186 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') { 186 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'web-videos') {
187 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?` 187 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
188 const res = await this.confirmService.confirm(message, $localize`Delete file`) 188 const res = await this.confirmService.confirm(message, $localize`Delete file`)
189 if (res === false) return 189 if (res === false) return
@@ -219,9 +219,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
219 } 219 }
220 220
221 private async removeVideos (videos: Video[]) { 221 private async removeVideos (videos: Video[]) {
222 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)( 222 const message = formatICU(
223 { count: videos.length }, 223 $localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`,
224 $localize`Are you sure you want to delete these ${videos.length} videos?` 224 { count: videos.length }
225 ) 225 )
226 226
227 const res = await this.confirmService.confirm(message, $localize`Delete`) 227 const res = await this.confirmService.confirm(message, $localize`Delete`)
@@ -231,9 +231,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
231 .subscribe({ 231 .subscribe({
232 next: () => { 232 next: () => {
233 this.notifier.success( 233 this.notifier.success(
234 prepareIcu($localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`)( 234 formatICU(
235 { count: videos.length }, 235 $localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`,
236 $localize`Deleted ${videos.length} videos.` 236 { count: videos.length }
237 ) 237 )
238 ) 238 )
239 239
@@ -249,9 +249,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
249 .subscribe({ 249 .subscribe({
250 next: () => { 250 next: () => {
251 this.notifier.success( 251 this.notifier.success(
252 prepareIcu($localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`)( 252 formatICU(
253 { count: videos.length }, 253 $localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`,
254 $localize`Unblocked ${videos.length} videos.` 254 { count: videos.length }
255 ) 255 )
256 ) 256 )
257 257
@@ -262,20 +262,20 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
262 }) 262 })
263 } 263 }
264 264
265 private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') { 265 private async removeVideoFiles (videos: Video[], type: 'hls' | 'web-videos') {
266 let message: string 266 let message: string
267 267
268 if (type === 'hls') { 268 if (type === 'hls') {
269 // eslint-disable-next-line max-len 269 // eslint-disable-next-line max-len
270 message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`)( 270 message = formatICU(
271 { count: videos.length }, 271 $localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`,
272 $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` 272 { count: videos.length }
273 ) 273 )
274 } else { 274 } else {
275 // eslint-disable-next-line max-len 275 // eslint-disable-next-line max-len
276 message = prepareIcu($localize`Are you sure you want to delete WebTorrent files of {count, plural, =1 {1 video} other {{count} videos}}?`)( 276 message = formatICU(
277 { count: videos.length }, 277 $localize`Are you sure you want to delete Web Video files of {count, plural, =1 {1 video} other {{count} videos}}?`,
278 $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` 278 { count: videos.length }
279 ) 279 )
280 } 280 }
281 281
@@ -293,7 +293,7 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
293 }) 293 })
294 } 294 }
295 295
296 private runTranscoding (videos: Video[], type: 'hls' | 'webtorrent') { 296 private runTranscoding (videos: Video[], type: 'hls' | 'web-video') {
297 this.videoService.runTranscoding(videos.map(v => v.id), type) 297 this.videoService.runTranscoding(videos.map(v => v.id), type)
298 .subscribe({ 298 .subscribe({
299 next: () => { 299 next: () => {
diff --git a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
index 8ba956eb8..8994c1d00 100644
--- a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
+++ b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
@@ -1,7 +1,7 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { Component, OnInit } from '@angular/core' 2import { Component, OnInit } from '@angular/core'
3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { DropdownAction } from '@app/shared/shared-main' 5import { DropdownAction } from '@app/shared/shared-main'
6import { RunnerJob, RunnerJobState } from '@shared/models' 6import { RunnerJob, RunnerJobState } from '@shared/models'
7import { RunnerJobFormatted, RunnerService } from '../runner.service' 7import { RunnerJobFormatted, RunnerService } from '../runner.service'
@@ -57,9 +57,10 @@ export class RunnerJobListComponent extends RestTable <RunnerJob> implements OnI
57 } 57 }
58 58
59 async cancelJobs (jobs: RunnerJob[]) { 59 async cancelJobs (jobs: RunnerJob[]) {
60 const message = prepareIcu( 60 const message = formatICU(
61 $localize`Do you really want to cancel {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be cancelled.` 61 $localize`Do you really want to cancel {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be cancelled.`,
62 )({ count: jobs.length }, $localize`Do you really want to cancel these jobs? Children jobs will also be cancelled.`) 62 { count: jobs.length }
63 )
63 64
64 const res = await this.confirmService.confirm(message, $localize`Cancel`) 65 const res = await this.confirmService.confirm(message, $localize`Cancel`)
65 66
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
index 97ffb6013..393c3ad6b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
@@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit {
30 async disableTwoFactor () { 30 async disableTwoFactor () {
31 const message = $localize`Are you sure you want to disable two factor authentication of your account?` 31 const message = $localize`Are you sure you want to disable two factor authentication of your account?`
32 32
33 const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`) 33 const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` })
34 if (confirmed === false) return 34 if (confirmed === false) return
35 35
36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) 36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
index 633720a6c..4d5dbbc2b 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
@@ -54,7 +54,7 @@ export class MyVideoChannelsComponent {
54 const res = await this.confirmService.confirmWithExpectedInput( 54 const res = await this.confirmService.confirmWithExpectedInput(
55 $localize`Do you really want to delete ${videoChannel.displayName}? 55 $localize`Do you really want to delete ${videoChannel.displayName}?
56It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another 56It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
57channel with the same name (${videoChannel.name})!`, 57channel or account with the same name (${videoChannel.name})!`,
58 58
59 $localize`Please type the name of the video channel (${videoChannel.name}) to confirm`, 59 $localize`Please type the name of the video channel (${videoChannel.name}) to confirm`,
60 60
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
index 57b8bdf7d..1827d6a0b 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.ts
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -5,7 +5,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'
5import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
6import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core' 6import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core'
7import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' 7import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
8import { immutableAssign, prepareIcu } from '@app/helpers' 8import { immutableAssign, formatICU } from '@app/helpers'
9import { AdvancedInputFilter } from '@app/shared/shared-forms' 9import { AdvancedInputFilter } from '@app/shared/shared-forms'
10import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 10import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
11import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 11import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
@@ -184,9 +184,9 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
184 .map(([ k, _v ]) => parseInt(k, 10)) 184 .map(([ k, _v ]) => parseInt(k, 10))
185 185
186 const res = await this.confirmService.confirm( 186 const res = await this.confirmService.confirm(
187 prepareIcu($localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`)( 187 formatICU(
188 { length: toDeleteVideosIds.length }, 188 $localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`,
189 $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?` 189 { length: toDeleteVideosIds.length }
190 ), 190 ),
191 $localize`Delete` 191 $localize`Delete`
192 ) 192 )
@@ -205,9 +205,9 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
205 .subscribe({ 205 .subscribe({
206 next: () => { 206 next: () => {
207 this.notifier.success( 207 this.notifier.success(
208 prepareIcu($localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`)( 208 formatICU(
209 { length: toDeleteVideosIds.length }, 209 $localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`,
210 $localize`${toDeleteVideosIds.length} have been deleted.` 210 { length: toDeleteVideosIds.length }
211 ) 211 )
212 ) 212 )
213 213
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index b607dabe9..97b713874 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -120,7 +120,12 @@
120 </div> 120 </div>
121 </div> 121 </div>
122 122
123 <div *ngIf="schedulePublicationEnabled" class="form-group"> 123 <div *ngIf="passwordProtectionSelected" class="form-group">
124 <label i18n for="videoPassword">Password</label>
125 <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
126 </div>
127
128 <div *ngIf="schedulePublicationSelected" class="form-group">
124 <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> 129 <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
125 <p-calendar 130 <p-calendar
126 id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" 131 id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
@@ -287,7 +292,7 @@
287 <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> 292 <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
288 <label i18n for="replayPrivacy">Privacy of the new replay</label> 293 <label i18n for="replayPrivacy">Privacy of the new replay</label>
289 <my-select-options 294 <my-select-options
290 labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy" 295 labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy"
291 ></my-select-options> 296 ></my-select-options>
292 </div> 297 </div>
293 298
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 8ed54ce6b..5e5df8db7 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -14,6 +14,7 @@ import {
14 VIDEO_LICENCE_VALIDATOR, 14 VIDEO_LICENCE_VALIDATOR,
15 VIDEO_NAME_VALIDATOR, 15 VIDEO_NAME_VALIDATOR,
16 VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, 16 VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
17 VIDEO_PASSWORD_VALIDATOR,
17 VIDEO_PRIVACY_VALIDATOR, 18 VIDEO_PRIVACY_VALIDATOR,
18 VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, 19 VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
19 VIDEO_SUPPORT_VALIDATOR, 20 VIDEO_SUPPORT_VALIDATOR,
@@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
79 // So that it can be accessed in the template 80 // So that it can be accessed in the template
80 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 81 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
81 82
82 videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 83 videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = []
84 replayPrivacies: VideoConstant<VideoPrivacy> [] = []
83 videoCategories: VideoConstant<number>[] = [] 85 videoCategories: VideoConstant<number>[] = []
84 videoLicences: VideoConstant<number>[] = [] 86 videoLicences: VideoConstant<number>[] = []
85 videoLanguages: VideoLanguages[] = [] 87 videoLanguages: VideoLanguages[] = []
@@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
103 105
104 pluginDataFormGroup: FormGroup 106 pluginDataFormGroup: FormGroup
105 107
106 schedulePublicationEnabled = false 108 schedulePublicationSelected = false
109 passwordProtectionSelected = false
107 110
108 calendarLocale: any = {} 111 calendarLocale: any = {}
109 minScheduledDate = new Date() 112 minScheduledDate = new Date()
@@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
148 const obj: { [ id: string ]: BuildFormValidator } = { 151 const obj: { [ id: string ]: BuildFormValidator } = {
149 name: VIDEO_NAME_VALIDATOR, 152 name: VIDEO_NAME_VALIDATOR,
150 privacy: VIDEO_PRIVACY_VALIDATOR, 153 privacy: VIDEO_PRIVACY_VALIDATOR,
154 videoPassword: VIDEO_PASSWORD_VALIDATOR,
151 channelId: VIDEO_CHANNEL_VALIDATOR, 155 channelId: VIDEO_CHANNEL_VALIDATOR,
152 nsfw: null, 156 nsfw: null,
153 commentsEnabled: null, 157 commentsEnabled: null,
@@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
222 226
223 this.serverService.getVideoPrivacies() 227 this.serverService.getVideoPrivacies()
224 .subscribe(privacies => { 228 .subscribe(privacies => {
225 this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies 229 const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
230 this.videoPrivacies = videoPrivacies
231 this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED)
226 232
227 // Can't schedule publication if private privacy is not available (could be deleted by a plugin) 233 // Can't schedule publication if private privacy is not available (could be deleted by a plugin)
228 const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) 234 const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
@@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
410 .subscribe( 416 .subscribe(
411 newPrivacyId => { 417 newPrivacyId => {
412 418
413 this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY 419 this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
414 420
415 // Value changed 421 // Value changed
416 const scheduleControl = this.form.get('schedulePublicationAt') 422 const scheduleControl = this.form.get('schedulePublicationAt')
417 const waitTranscodingControl = this.form.get('waitTranscoding') 423 const waitTranscodingControl = this.form.get('waitTranscoding')
418 424
419 if (this.schedulePublicationEnabled) { 425 if (this.schedulePublicationSelected) {
420 scheduleControl.setValidators([ Validators.required ]) 426 scheduleControl.setValidators([ Validators.required ])
421 427
422 waitTranscodingControl.disable() 428 waitTranscodingControl.disable()
@@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
437 443
438 this.firstPatchDone = true 444 this.firstPatchDone = true
439 445
446 this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED
447 const videoPasswordControl = this.form.get('videoPassword')
448
449 if (this.passwordProtectionSelected) {
450 videoPasswordControl.setValidators([ Validators.required ])
451 } else {
452 videoPasswordControl.clearValidators()
453 }
454 videoPasswordControl.updateValueAndValidity()
455
440 } 456 }
441 ) 457 )
442 } 458 }
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ad71162b8..e51047e8c 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -10,7 +10,7 @@ import { LiveVideoService } from '@app/shared/shared-video-live'
10import { LoadingBarService } from '@ngx-loading-bar/core' 10import { LoadingBarService } from '@ngx-loading-bar/core'
11import { logger } from '@root-helpers/logger' 11import { logger } from '@root-helpers/logger'
12import { pick, simpleObjectsDeepEqual } from '@shared/core-utils' 12import { pick, simpleObjectsDeepEqual } from '@shared/core-utils'
13import { LiveVideo, LiveVideoUpdate, VideoPrivacy } from '@shared/models' 13import { LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
14import { VideoSource } from '@shared/models/videos/video-source' 14import { VideoSource } from '@shared/models/videos/video-source'
15import { hydrateFormFromVideo } from './shared/video-edit-utils' 15import { hydrateFormFromVideo } from './shared/video-edit-utils'
16 16
@@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
49 this.buildForm({}) 49 this.buildForm({})
50 50
51 const { videoData } = this.route.snapshot.data 51 const { videoData } = this.route.snapshot.data
52 const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData 52 const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
53 53
54 this.videoDetails = video 54 this.videoDetails = video
55 this.videoEdit = new VideoEdit(this.videoDetails) 55 this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
56 56
57 this.userVideoChannels = videoChannels 57 this.userVideoChannels = videoChannels
58 this.videoCaptions = videoCaptions 58 this.videoCaptions = videoCaptions
@@ -98,11 +98,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
98 } 98 }
99 99
100 isWaitTranscodingHidden () { 100 isWaitTranscodingHidden () {
101 if (this.videoDetails.getFiles().length > 1) { // Already transcoded 101 return this.videoDetails.state.id !== VideoState.TO_TRANSCODE
102 return true
103 }
104
105 return false
106 } 102 }
107 103
108 async update () { 104 async update () {
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index 6612d22de..2c99b36a8 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -4,8 +4,9 @@ import { Injectable } from '@angular/core'
4import { ActivatedRouteSnapshot } from '@angular/router' 4import { ActivatedRouteSnapshot } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { listUserChannelsForSelect } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { VideoPrivacy } from '@shared/models/videos'
9 10
10@Injectable() 11@Injectable()
11export class VideoUpdateResolver { 12export class VideoUpdateResolver {
@@ -13,7 +14,8 @@ export class VideoUpdateResolver {
13 private videoService: VideoService, 14 private videoService: VideoService,
14 private liveVideoService: LiveVideoService, 15 private liveVideoService: LiveVideoService,
15 private authService: AuthService, 16 private authService: AuthService,
16 private videoCaptionService: VideoCaptionService 17 private videoCaptionService: VideoCaptionService,
18 private videoPasswordService: VideoPasswordService
17 ) { 19 ) {
18 } 20 }
19 21
@@ -21,11 +23,11 @@ export class VideoUpdateResolver {
21 const uuid: string = route.params['uuid'] 23 const uuid: string = route.params['uuid']
22 24
23 return this.videoService.getVideo({ videoId: uuid }) 25 return this.videoService.getVideo({ videoId: uuid })
24 .pipe( 26 .pipe(
25 switchMap(video => forkJoin(this.buildVideoObservables(video))), 27 switchMap(video => forkJoin(this.buildVideoObservables(video))),
26 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) => 28 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
27 ({ video, videoChannels, videoCaptions, videoSource, liveVideo })) 29 ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
28 ) 30 )
29 } 31 }
30 32
31 private buildVideoObservables (video: VideoDetails) { 33 private buildVideoObservables (video: VideoDetails) {
@@ -46,6 +48,10 @@ export class VideoUpdateResolver {
46 48
47 video.isLive 49 video.isLive
48 ? this.liveVideoService.getVideoLive(video.id) 50 ? this.liveVideoService.getVideoLive(video.id)
51 : of(undefined),
52
53 video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
54 ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
49 : of(undefined) 55 : of(undefined)
50 ] 56 ]
51 } 57 }
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
index cf32e371a..140a391e9 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
@@ -1,7 +1,7 @@
1<div class="video-actions-rates"> 1<div class="video-actions-rates">
2 <div class="video-actions justify-content-end"> 2 <div class="video-actions justify-content-end">
3 <my-video-rate 3 <my-video-rate
4 [video]="video" [isUserLoggedIn]="isUserLoggedIn" 4 [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn"
5 (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)" 5 (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
6 ></my-video-rate> 6 ></my-video-rate>
7 7
@@ -20,7 +20,7 @@
20 20
21 <div 21 <div
22 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" 22 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
23 *ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)" 23 *ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)"
24 [ngbTooltip]="tooltipSaveToPlaylist" 24 [ngbTooltip]="tooltipSaveToPlaylist"
25 placement="bottom auto" 25 placement="bottom auto"
26 > 26 >
@@ -43,7 +43,7 @@
43 <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span> 43 <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
44 </button> 44 </button>
45 45
46 <my-video-download #videoDownloadModal></my-video-download> 46 <my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
47 </ng-container> 47 </ng-container>
48 48
49 <ng-container *ngIf="isUserLoggedIn"> 49 <ng-container *ngIf="isUserLoggedIn">
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index 51718827d..e6c0d4de1 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -5,7 +5,7 @@ import { VideoShareComponent } from '@app/shared/shared-share-modal'
5import { SupportModalComponent } from '@app/shared/shared-support-modal' 5import { SupportModalComponent } from '@app/shared/shared-support-modal'
6import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' 6import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
7import { VideoPlaylist } from '@app/shared/shared-video-playlist' 7import { VideoPlaylist } from '@app/shared/shared-video-playlist'
8import { UserVideoRateType, VideoCaption } from '@shared/models/videos' 8import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos'
9 9
10@Component({ 10@Component({
11 selector: 'my-action-buttons', 11 selector: 'my-action-buttons',
@@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
18 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent 18 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
19 19
20 @Input() video: VideoDetails 20 @Input() video: VideoDetails
21 @Input() videoPassword: string
21 @Input() videoCaptions: VideoCaption[] 22 @Input() videoCaptions: VideoCaption[]
22 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
23 24
24 @Input() isUserLoggedIn: boolean 25 @Input() isUserLoggedIn: boolean
26 @Input() isUserOwner: boolean
25 27
26 @Input() currentTime: number 28 @Input() currentTime: number
27 @Input() currentPlaylistPosition: number 29 @Input() currentPlaylistPosition: number
@@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
92 private setVideoLikesBarTooltipText () { 94 private setVideoLikesBarTooltipText () {
93 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` 95 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
94 } 96 }
97
98 isVideoAddableToPlaylist () {
99 const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
100
101 if (!this.isUserLoggedIn) return false
102
103 if (isPasswordProtected) return this.isUserOwner
104
105 return true
106 }
95} 107}
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
index d0c138834..11966ce34 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
@@ -12,6 +12,7 @@ import { UserVideoRateType } from '@shared/models'
12}) 12})
13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { 13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
14 @Input() video: VideoDetails 14 @Input() video: VideoDetails
15 @Input() videoPassword: string
15 @Input() isUserLoggedIn: boolean 16 @Input() isUserLoggedIn: boolean
16 17
17 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>() 18 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
@@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
103 } 104 }
104 105
105 private setRating (nextRating: UserVideoRateType) { 106 private setRating (nextRating: UserVideoRateType) {
106 const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable<any> } = { 107 const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable<any> } = {
107 like: this.videoService.setVideoLike, 108 like: this.videoService.setVideoLike,
108 dislike: this.videoService.setVideoDislike, 109 dislike: this.videoService.setVideoDislike,
109 none: this.videoService.unsetVideoLike 110 none: this.videoService.unsetVideoLike
110 } 111 }
111 112
112 ratingMethods[nextRating].call(this.videoService, this.video.uuid) 113 ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword)
113 .subscribe({ 114 .subscribe({
114 next: () => { 115 next: () => {
115 // Update the video like attribute 116 // Update the video like attribute
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
index 033097084..1d9e10d0a 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
@@ -29,6 +29,7 @@ import { VideoCommentCreate } from '@shared/models'
29export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { 29export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
30 @Input() user: User 30 @Input() user: User
31 @Input() video: Video 31 @Input() video: Video
32 @Input() videoPassword: string
32 @Input() parentComment?: VideoComment 33 @Input() parentComment?: VideoComment
33 @Input() parentComments?: VideoComment[] 34 @Input() parentComments?: VideoComment[]
34 @Input() focusOnInit = false 35 @Input() focusOnInit = false
@@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
176 177
177 private addCommentReply (commentCreate: VideoCommentCreate) { 178 private addCommentReply (commentCreate: VideoCommentCreate) {
178 return this.videoCommentService 179 return this.videoCommentService
179 .addCommentReply(this.video.uuid, this.parentComment.id, commentCreate) 180 .addCommentReply({
181 videoId: this.video.uuid,
182 inReplyToCommentId: this.parentComment.id,
183 comment: commentCreate,
184 videoPassword: this.videoPassword
185 })
180 } 186 }
181 187
182 private addCommentThread (commentCreate: VideoCommentCreate) { 188 private addCommentThread (commentCreate: VideoCommentCreate) {
183 return this.videoCommentService 189 return this.videoCommentService
184 .addCommentThread(this.video.uuid, commentCreate) 190 .addCommentThread(this.video.uuid, commentCreate, this.videoPassword)
185 } 191 }
186 192
187 private initTextValue () { 193 private initTextValue () {
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
index 91bd8309c..80ea22a20 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
@@ -62,6 +62,7 @@
62 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id" 62 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
63 [user]="user" 63 [user]="user"
64 [video]="video" 64 [video]="video"
65 [videoPassword]="videoPassword"
65 [parentComment]="comment" 66 [parentComment]="comment"
66 [parentComments]="newParentComments" 67 [parentComments]="newParentComments"
67 [focusOnInit]="true" 68 [focusOnInit]="true"
@@ -75,6 +76,7 @@
75 <my-video-comment 76 <my-video-comment
76 [comment]="commentChild.comment" 77 [comment]="commentChild.comment"
77 [video]="video" 78 [video]="video"
79 [videoPassword]="videoPassword"
78 [inReplyToCommentId]="inReplyToCommentId" 80 [inReplyToCommentId]="inReplyToCommentId"
79 [commentTree]="commentChild" 81 [commentTree]="commentChild"
80 [parentComments]="newParentComments" 82 [parentComments]="newParentComments"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
index 191ec4a28..4c85df657 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
@@ -16,6 +16,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent 16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
17 17
18 @Input() video: Video 18 @Input() video: Video
19 @Input() videoPassword: string
19 @Input() comment: VideoComment 20 @Input() comment: VideoComment
20 @Input() parentComments: VideoComment[] = [] 21 @Input() parentComments: VideoComment[] = []
21 @Input() commentTree: VideoCommentThreadTree 22 @Input() commentTree: VideoCommentThreadTree
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
index a003a10eb..0932d2b7f 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
@@ -20,6 +20,7 @@
20 <ng-template [ngIf]="video.commentsEnabled === true"> 20 <ng-template [ngIf]="video.commentsEnabled === true">
21 <my-video-comment-add 21 <my-video-comment-add
22 [video]="video" 22 [video]="video"
23 [videoPassword]="videoPassword"
23 [user]="user" 24 [user]="user"
24 (commentCreated)="onCommentThreadCreated($event)" 25 (commentCreated)="onCommentThreadCreated($event)"
25 [textValue]="commentThreadRedraftValue" 26 [textValue]="commentThreadRedraftValue"
@@ -34,6 +35,7 @@
34 *ngIf="highlightedThread" 35 *ngIf="highlightedThread"
35 [comment]="highlightedThread" 36 [comment]="highlightedThread"
36 [video]="video" 37 [video]="video"
38 [videoPassword]="videoPassword"
37 [inReplyToCommentId]="inReplyToCommentId" 39 [inReplyToCommentId]="inReplyToCommentId"
38 [commentTree]="threadComments[highlightedThread.id]" 40 [commentTree]="threadComments[highlightedThread.id]"
39 [highlightedComment]="true" 41 [highlightedComment]="true"
@@ -53,6 +55,7 @@
53 *ngIf="!highlightedThread || comment.id !== highlightedThread.id" 55 *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
54 [comment]="comment" 56 [comment]="comment"
55 [video]="video" 57 [video]="video"
58 [videoPassword]="videoPassword"
56 [inReplyToCommentId]="inReplyToCommentId" 59 [inReplyToCommentId]="inReplyToCommentId"
57 [commentTree]="threadComments[comment.id]" 60 [commentTree]="threadComments[comment.id]"
58 [firstInThread]="i + 1 !== comments.length" 61 [firstInThread]="i + 1 !== comments.length"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
index 96bdb28c9..848936f91 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
@@ -15,6 +15,7 @@ import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
15export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { 15export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
16 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef 16 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
17 @Input() video: VideoDetails 17 @Input() video: VideoDetails
18 @Input() videoPassword: string
18 @Input() user: User 19 @Input() user: User
19 20
20 @Output() timestampClicked = new EventEmitter<number>() 21 @Output() timestampClicked = new EventEmitter<number>()
@@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
80 81
81 const params = { 82 const params = {
82 videoId: this.video.uuid, 83 videoId: this.video.uuid,
83 threadId: commentId 84 threadId: commentId,
85 videoPassword: this.videoPassword
84 } 86 }
85 87
86 const obs = this.hooks.wrapObsFun( 88 const obs = this.hooks.wrapObsFun(
@@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
119 loadMoreThreads () { 121 loadMoreThreads () {
120 const params = { 122 const params = {
121 videoId: this.video.uuid, 123 videoId: this.video.uuid,
124 videoPassword: this.videoPassword,
122 componentPagination: this.componentPagination, 125 componentPagination: this.componentPagination,
123 sort: this.sort 126 sort: this.sort
124 } 127 }
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
index 79b83811d..45e222743 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
@@ -42,3 +42,7 @@
42 <div class="blocked-label" i18n>This video is blocked.</div> 42 <div class="blocked-label" i18n>This video is blocked.</div>
43 {{ video.blacklistedReason }} 43 {{ video.blacklistedReason }}
44</div> 44</div>
45
46<div i18n class="alert alert-warning" *ngIf="video?.canAccessPasswordProtectedVideoWithoutPassword(user)">
47 This video is password protected.
48</div>
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
index ba79fabc8..8781ead7e 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
@@ -1,6 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { AuthUser } from '@app/core'
2import { VideoDetails } from '@app/shared/shared-main' 3import { VideoDetails } from '@app/shared/shared-main'
3import { VideoState } from '@shared/models' 4import { VideoPrivacy, VideoState } from '@shared/models'
4 5
5@Component({ 6@Component({
6 selector: 'my-video-alert', 7 selector: 'my-video-alert',
@@ -8,6 +9,7 @@ import { VideoState } from '@shared/models'
8 styleUrls: [ './video-alert.component.scss' ] 9 styleUrls: [ './video-alert.component.scss' ]
9}) 10})
10export class VideoAlertComponent { 11export class VideoAlertComponent {
12 @Input() user: AuthUser
11 @Input() video: VideoDetails 13 @Input() video: VideoDetails
12 @Input() noPlaylistVideoFound: boolean 14 @Input() noPlaylistVideoFound: boolean
13 15
@@ -46,4 +48,8 @@ export class VideoAlertComponent {
46 isLiveEnded () { 48 isLiveEnded () {
47 return this.video?.state.id === VideoState.LIVE_ENDED 49 return this.video?.state.id === VideoState.LIVE_ENDED
48 } 50 }
51
52 isVideoPasswordProtected () {
53 return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
54 }
49} 55}
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 461891779..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
@@ -19,7 +19,7 @@
19 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> 19 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
20 </div> 20 </div>
21 21
22 <my-video-alert [video]="video" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert> 22 <my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
23 23
24 <!-- Video information --> 24 <!-- Video information -->
25 <div *ngIf="video" class="margin-content video-bottom"> 25 <div *ngIf="video" class="margin-content video-bottom">
@@ -51,8 +51,8 @@
51 </div> 51 </div>
52 52
53 <my-action-buttons 53 <my-action-buttons
54 [video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist" 54 [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
55 [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" 55 [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
56 ></my-action-buttons> 56 ></my-action-buttons>
57 </div> 57 </div>
58 </div> 58 </div>
@@ -92,6 +92,7 @@
92 <my-video-comments 92 <my-video-comments
93 class="border-top" 93 class="border-top"
94 [video]="video" 94 [video]="video"
95 [videoPassword]="videoPassword"
95 [user]="user" 96 [user]="user"
96 (timestampClicked)="handleTimestampClicked($event)" 97 (timestampClicked)="handleTimestampClicked($event)"
97 ></my-video-comments> 98 ></my-video-comments>
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 19ad97d42..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 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' 2import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
3import { VideoJsPlayer } from 'video.js'
4import { PlatformLocation } from '@angular/common' 3import { PlatformLocation } from '@angular/common'
5import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
6import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
@@ -19,13 +18,13 @@ import {
19 UserService 18 UserService
20} from '@app/core' 19} from '@app/core'
21import { HooksService } from '@app/core/plugins/hooks.service' 20import { HooksService } from '@app/core/plugins/hooks.service'
22import { isXPercentInViewport, scrollToTop } from '@app/helpers' 21import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
23import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' 22import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
24import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' 23import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
25import { LiveVideoService } from '@app/shared/shared-video-live' 24import { LiveVideoService } from '@app/shared/shared-video-live'
26import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 25import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
27import { logger } from '@root-helpers/logger' 26import { logger } from '@root-helpers/logger'
28import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' 27import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
29import { timeToInt } from '@shared/core-utils' 28import { timeToInt } from '@shared/core-utils'
30import { 29import {
31 HTMLServerConfig, 30 HTMLServerConfig,
@@ -33,15 +32,16 @@ import {
33 LiveVideo, 32 LiveVideo,
34 PeerTubeProblemDocument, 33 PeerTubeProblemDocument,
35 ServerErrorCode, 34 ServerErrorCode,
35 Storyboard,
36 VideoCaption, 36 VideoCaption,
37 VideoPrivacy, 37 VideoPrivacy,
38 VideoState 38 VideoState
39} from '@shared/models' 39} from '@shared/models'
40import { 40import {
41 CustomizationOptions, 41 HLSOptions,
42 P2PMediaLoaderOptions, 42 PeerTubePlayer,
43 PeertubePlayerManager, 43 PeerTubePlayerContructorOptions,
44 PeertubePlayerManagerOptions, 44 PeerTubePlayerLoadOptions,
45 PlayerMode, 45 PlayerMode,
46 videojs 46 videojs
47} from '../../../assets/player' 47} from '../../../assets/player'
@@ -49,7 +49,24 @@ import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from
49import { environment } from '../../../environments/environment' 49import { environment } from '../../../environments/environment'
50import { VideoWatchPlaylistComponent } from './shared' 50import { VideoWatchPlaylistComponent } from './shared'
51 51
52type URLOptions = CustomizationOptions & { playerMode: PlayerMode } 52type 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}
53 70
54@Component({ 71@Component({
55 selector: 'my-video-watch', 72 selector: 'my-video-watch',
@@ -59,15 +76,16 @@ type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
59export class VideoWatchComponent implements OnInit, OnDestroy { 76export class VideoWatchComponent implements OnInit, OnDestroy {
60 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent 77 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
61 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent 78 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
79 @ViewChild('playerElement') playerElement: ElementRef<HTMLVideoElement>
62 80
63 player: VideoJsPlayer 81 peertubePlayer: PeerTubePlayer
64 playerElement: HTMLVideoElement
65 playerPlaceholderImgSrc: string
66 theaterEnabled = false 82 theaterEnabled = false
67 83
68 video: VideoDetails = null 84 video: VideoDetails = null
69 videoCaptions: VideoCaption[] = [] 85 videoCaptions: VideoCaption[] = []
70 liveVideo: LiveVideo 86 liveVideo: LiveVideo
87 videoPassword: string
88 storyboards: Storyboard[] = []
71 89
72 playlistPosition: number 90 playlistPosition: number
73 playlist: VideoPlaylist = null 91 playlist: VideoPlaylist = null
@@ -75,8 +93,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
75 remoteServerDown = false 93 remoteServerDown = false
76 noPlaylistVideoFound = false 94 noPlaylistVideoFound = false
77 95
78 private nextVideoUUID = '' 96 private nextRecommendedVideoUUID = ''
79 private nextVideoTitle = '' 97 private nextRecommendedVideoTitle = ''
80 98
81 private videoFileToken: string 99 private videoFileToken: string
82 100
@@ -127,11 +145,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
127 return this.userService.getAnonymousUser() 145 return this.userService.getAnonymousUser()
128 } 146 }
129 147
130 ngOnInit () { 148 async ngOnInit () {
131 this.serverConfig = this.serverService.getHTMLConfig() 149 this.serverConfig = this.serverService.getHTMLConfig()
132 150
133 PeertubePlayerManager.initState()
134
135 this.loadRouteParams() 151 this.loadRouteParams()
136 this.loadRouteQuery() 152 this.loadRouteQuery()
137 153
@@ -140,10 +156,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
140 this.hooks.runAction('action:video-watch.init', 'video-watch') 156 this.hooks.runAction('action:video-watch.init', 'video-watch')
141 157
142 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)
143 } 169 }
144 170
145 ngOnDestroy () { 171 ngOnDestroy () {
146 this.flushPlayer() 172 if (this.peertubePlayer) this.peertubePlayer.destroy()
147 173
148 // Unsubscribe subscriptions 174 // Unsubscribe subscriptions
149 if (this.paramsSub) this.paramsSub.unsubscribe() 175 if (this.paramsSub) this.paramsSub.unsubscribe()
@@ -168,14 +194,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
168 194
169 // The recommended videos's first element should be the next video 195 // The recommended videos's first element should be the next video
170 const video = videos[0] 196 const video = videos[0]
171 this.nextVideoUUID = video.uuid 197 this.nextRecommendedVideoUUID = video.uuid
172 this.nextVideoTitle = video.name 198 this.nextRecommendedVideoTitle = video.name
173 } 199 }
174 200
175 handleTimestampClicked (timestamp: number) { 201 handleTimestampClicked (timestamp: number) {
176 if (!this.player || this.video.isLive) return 202 if (!this.peertubePlayer || this.video.isLive) return
177 203
178 this.player.currentTime(timestamp) 204 this.peertubePlayer.getPlayer().currentTime(timestamp)
179 scrollToTop() 205 scrollToTop()
180 } 206 }
181 207
@@ -191,6 +217,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
191 return this.authService.isLoggedIn() 217 return this.authService.isLoggedIn()
192 } 218 }
193 219
220 isUserOwner () {
221 return this.video.isLocal === true && this.video.account.name === this.user?.username
222 }
223
194 isVideoBlur (video: Video) { 224 isVideoBlur (video: Video) {
195 return video.isVideoNSFWForUser(this.user, this.serverConfig) 225 return video.isVideoNSFWForUser(this.user, this.serverConfig)
196 } 226 }
@@ -236,25 +266,24 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
236 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition) 266 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
237 267
238 const start = queryParams['start'] 268 const start = queryParams['start']
239 if (this.player && start) this.player.currentTime(parseInt(start, 10)) 269 if (this.peertubePlayer && start) this.peertubePlayer.getPlayer().currentTime(parseInt(start, 10))
240 }) 270 })
241 } 271 }
242 272
243 private loadVideo (options: { 273 private loadVideo (options: {
244 videoId: string 274 videoId: string
245 forceAutoplay: boolean 275 forceAutoplay: boolean
276 videoPassword?: string
246 }) { 277 }) {
247 const { videoId, forceAutoplay } = options 278 const { videoId, forceAutoplay, videoPassword } = options
248 279
249 if (this.isSameElement(this.video, videoId)) return 280 if (this.isSameElement(this.video, videoId)) return
250 281
251 if (this.player) this.player.pause()
252
253 this.video = undefined 282 this.video = undefined
254 283
255 const videoObs = this.hooks.wrapObsFun( 284 const videoObs = this.hooks.wrapObsFun(
256 this.videoService.getVideo.bind(this.videoService), 285 this.videoService.getVideo.bind(this.videoService),
257 { videoId }, 286 { videoId, videoPassword },
258 'video-watch', 287 'video-watch',
259 'filter:api.video-watch.video.get.params', 288 'filter:api.video-watch.video.get.params',
260 'filter:api.video-watch.video.get.result' 289 'filter:api.video-watch.video.get.result'
@@ -269,48 +298,44 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
269 }), 298 }),
270 299
271 switchMap(({ video, live }) => { 300 switchMap(({ video, live }) => {
272 if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined }) 301 if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined })
273 302
274 return this.videoFileTokenService.getVideoFileToken(video.uuid) 303 return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword })
275 .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) 304 .pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
276 }) 305 })
277 ) 306 )
278 307
279 forkJoin([ 308 forkJoin([
280 videoAndLiveObs, 309 videoAndLiveObs,
281 this.videoCaptionService.listCaptions(videoId), 310 this.videoCaptionService.listCaptions(videoId, videoPassword),
311 this.videoService.getStoryboards(videoId, videoPassword),
282 this.userService.getAnonymousOrLoggedUser() 312 this.userService.getAnonymousOrLoggedUser()
283 ]).subscribe({ 313 ]).subscribe({
284 next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { 314 next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
285 const queryParams = this.route.snapshot.queryParams
286
287 const urlOptions = {
288 resume: queryParams.resume,
289
290 startTime: queryParams.start,
291 stopTime: queryParams.stop,
292
293 muted: queryParams.muted,
294 loop: queryParams.loop,
295 subtitle: queryParams.subtitle,
296
297 playerMode: queryParams.mode,
298 playbackRate: queryParams.playbackRate,
299 peertubeLink: false
300 }
301
302 this.onVideoFetched({ 315 this.onVideoFetched({
303 video, 316 video,
304 live, 317 live,
305 videoCaptions: captionsResult.data, 318 videoCaptions: captionsResult.data,
319 storyboards,
306 videoFileToken, 320 videoFileToken,
321 videoPassword,
307 loggedInOrAnonymousUser, 322 loggedInOrAnonymousUser,
308 urlOptions,
309 forceAutoplay 323 forceAutoplay
310 }).catch(err => this.handleGlobalError(err)) 324 }).catch(err => {
325 this.handleGlobalError(err)
326 })
311 }, 327 },
328 error: async err => {
329 if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
330 const { confirmed, password } = await this.handleVideoPasswordError(err)
312 331
313 error: err => this.handleRequestError(err) 332 if (confirmed === false) return this.location.back()
333
334 this.loadVideo({ ...options, videoPassword: password })
335 } else {
336 this.handleRequestError(err)
337 }
338 }
314 }) 339 })
315 } 340 }
316 341
@@ -364,28 +389,47 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
364 const errorMessage: string = typeof err === 'string' ? err : err.message 389 const errorMessage: string = typeof err === 'string' ? err : err.message
365 if (!errorMessage) return 390 if (!errorMessage) return
366 391
367 // Display a message in the video player instead of a notification 392 this.notifier.error(errorMessage)
368 if (errorMessage.includes('from xs param')) { 393 }
369 this.flushPlayer()
370 this.remoteServerDown = true
371 394
372 return 395 private handleVideoPasswordError (err: any) {
396 let isIncorrectPassword: boolean
397
398 if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) {
399 isIncorrectPassword = false
400 } else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
401 this.videoPassword = undefined
402 isIncorrectPassword = true
373 } 403 }
374 404
375 this.notifier.error(errorMessage) 405 return this.confirmService.confirmWithPassword({
406 message: $localize`You need a password to watch this video`,
407 title: $localize`This video is password protected`,
408 errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : ''
409 })
376 } 410 }
377 411
378 private async onVideoFetched (options: { 412 private async onVideoFetched (options: {
379 video: VideoDetails 413 video: VideoDetails
380 live: LiveVideo 414 live: LiveVideo
381 videoCaptions: VideoCaption[] 415 videoCaptions: VideoCaption[]
416 storyboards: Storyboard[]
382 videoFileToken: string 417 videoFileToken: string
418 videoPassword: string
383 419
384 urlOptions: URLOptions
385 loggedInOrAnonymousUser: User 420 loggedInOrAnonymousUser: User
386 forceAutoplay: boolean 421 forceAutoplay: boolean
387 }) { 422 }) {
388 const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options 423 const {
424 video,
425 live,
426 videoCaptions,
427 storyboards,
428 videoFileToken,
429 videoPassword,
430 loggedInOrAnonymousUser,
431 forceAutoplay
432 } = options
389 433
390 this.subscribeToLiveEventsIfNeeded(this.video, video) 434 this.subscribeToLiveEventsIfNeeded(this.video, video)
391 435
@@ -393,9 +437,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
393 this.videoCaptions = videoCaptions 437 this.videoCaptions = videoCaptions
394 this.liveVideo = live 438 this.liveVideo = live
395 this.videoFileToken = videoFileToken 439 this.videoFileToken = videoFileToken
440 this.videoPassword = videoPassword
441 this.storyboards = storyboards
396 442
397 // Re init attributes 443 // Re init attributes
398 this.playerPlaceholderImgSrc = undefined
399 this.remoteServerDown = false 444 this.remoteServerDown = false
400 this.currentTime = undefined 445 this.currentTime = undefined
401 446
@@ -409,7 +454,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
409 454
410 this.buildHotkeysHelp(video) 455 this.buildHotkeysHelp(video)
411 456
412 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) 457 this.loadPlayer({ loggedInOrAnonymousUser, forceAutoplay })
413 .catch(err => logger.error('Cannot build the player', err)) 458 .catch(err => logger.error('Cannot build the player', err))
414 459
415 this.setOpenGraphTags() 460 this.setOpenGraphTags()
@@ -422,114 +467,70 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
422 this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions) 467 this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions)
423 } 468 }
424 469
425 private async buildPlayer (options: { 470 private async loadPlayer (options: {
426 urlOptions: URLOptions
427 loggedInOrAnonymousUser: User 471 loggedInOrAnonymousUser: User
428 forceAutoplay: boolean 472 forceAutoplay: boolean
429 }) { 473 }) {
430 const { urlOptions, loggedInOrAnonymousUser, forceAutoplay } = options 474 const { loggedInOrAnonymousUser, forceAutoplay } = options
431
432 // Flush old player if needed
433 this.flushPlayer()
434 475
435 const videoState = this.video.state.id 476 const videoState = this.video.state.id
436 if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { 477 if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
437 this.playerPlaceholderImgSrc = this.video.previewPath 478 this.updatePlayerOnNoLive()
438 return 479 return
439 } 480 }
440 481
441 // Build video element, because videojs removes it on dispose 482 this.peertubePlayer?.enable()
442 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
443 this.playerElement = document.createElement('video')
444 this.playerElement.className = 'video-js vjs-peertube-skin'
445 this.playerElement.setAttribute('playsinline', 'true')
446 playerElementWrapper.appendChild(this.playerElement)
447 483
448 const params = { 484 const params = {
449 video: this.video, 485 video: this.video,
450 videoCaptions: this.videoCaptions, 486 videoCaptions: this.videoCaptions,
487 storyboards: this.storyboards,
451 liveVideo: this.liveVideo, 488 liveVideo: this.liveVideo,
452 videoFileToken: this.videoFileToken, 489 videoFileToken: this.videoFileToken,
453 urlOptions, 490 videoPassword: this.videoPassword,
491 urlOptions: this.getUrlOptions(),
454 loggedInOrAnonymousUser, 492 loggedInOrAnonymousUser,
455 forceAutoplay, 493 forceAutoplay,
456 user: this.user 494 user: this.user
457 } 495 }
458 const { playerMode, playerOptions } = await this.hooks.wrapFun( 496
459 this.buildPlayerManagerOptions.bind(this), 497 const loadOptions = await this.hooks.wrapFun(
498 this.buildPeerTubePlayerLoadOptions.bind(this),
460 params, 499 params,
461 'video-watch', 500 'video-watch',
462 'filter:internal.video-watch.player.build-options.params', 501 'filter:internal.video-watch.player.load-options.params',
463 'filter:internal.video-watch.player.build-options.result' 502 'filter:internal.video-watch.player.load-options.result'
464 ) 503 )
465 504
466 this.zone.runOutsideAngular(async () => { 505 this.zone.runOutsideAngular(async () => {
467 this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) 506 await this.peertubePlayer.load(loadOptions)
468 507
469 this.player.on('customError', (_e, data: any) => { 508 const player = this.peertubePlayer.getPlayer()
470 this.zone.run(() => this.handleGlobalError(data.err))
471 })
472 509
473 this.player.on('timeupdate', () => { 510 player.on('timeupdate', () => {
474 // 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
475 this.currentTime = Math.floor(this.player.currentTime()) 512 this.currentTime = Math.floor(player.currentTime())
476 }) 513 })
477 514
478 /** 515 if (this.video.isLive) {
479 * condition: true to make the upnext functionality trigger, false to disable the upnext functionality 516 player.one('ended', () => {
480 * go to the next video in 'condition()' if you don't want of the timer. 517 this.zone.run(() => {
481 * next: function triggered at the end of the timer. 518 // We changed the video, it's not a live anymore
482 * suspended: function used at each click of the timer checking if we need to reset progress 519 if (!this.video.isLive) return
483 * and wait until suspended becomes truthy again.
484 */
485 this.player.upnext({
486 timeout: 5000, // 5s
487
488 headText: $localize`Up Next`,
489 cancelText: $localize`Cancel`,
490 suspendedText: $localize`Autoplay is suspended`,
491
492 getTitle: () => this.nextVideoTitle,
493 520
494 next: () => this.zone.run(() => this.playNextVideoInAngularZone()), 521 this.video.state.id = VideoState.LIVE_ENDED
495 condition: () => {
496 if (!this.playlist) return this.isAutoPlayNext()
497 522
498 // Don't wait timeout to play the next playlist video 523 this.updatePlayerOnNoLive()
499 if (this.isPlaylistAutoPlayNext()) { 524 })
500 this.playNextVideoInAngularZone() 525 })
501 return undefined 526 }
502 }
503
504 return false
505 },
506
507 suspended: () => {
508 return (
509 !isXPercentInViewport(this.player.el() as HTMLElement, 80) ||
510 !document.getElementById('content').contains(document.activeElement)
511 )
512 }
513 })
514
515 this.player.one('stopped', () => {
516 if (this.playlist && this.isPlaylistAutoPlayNext()) {
517 this.playNextVideoInAngularZone()
518 }
519 })
520
521 this.player.one('ended', () => {
522 if (this.video.isLive) {
523 this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED)
524 }
525 })
526 527
527 this.player.on('theaterChange', (_: any, enabled: boolean) => { 528 player.on('theater-change', (_: any, enabled: boolean) => {
528 this.zone.run(() => this.theaterEnabled = enabled) 529 this.zone.run(() => this.theaterEnabled = enabled)
529 }) 530 })
530 531
531 this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { 532 this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', {
532 player: this.player, 533 player,
533 playlist: this.playlist, 534 playlist: this.playlist,
534 playlistPosition: this.playlistPosition, 535 playlistPosition: this.playlistPosition,
535 videojs, 536 videojs,
@@ -546,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
546 return true 547 return true
547 } 548 }
548 549
549 private playNextVideoInAngularZone () { 550 private getNextVideoTitle () {
550 if (this.playlist) { 551 if (this.playlist) {
551 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) 552 return this.videoWatchPlaylist.getNextVideo()?.video?.name || ''
552 return
553 } 553 }
554 554
555 if (this.nextVideoUUID) { 555 return this.nextRecommendedVideoTitle
556 this.router.navigate([ '/w', this.nextVideoUUID ]) 556 }
557 } 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 })
558 } 569 }
559 570
560 private isAutoplay () { 571 private isAutoplay () {
@@ -582,32 +593,93 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
582 ) 593 )
583 } 594 }
584 595
585 private flushPlayer () { 596 private buildPeerTubePlayerConstructorOptions (options: {
586 // Remove player if it exists 597 urlOptions: URLOptions
587 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(),
588 623
589 try { 624 serverUrl: environment.originServerUrl || window.location.origin,
590 this.player.dispose() 625
591 this.player = undefined 626 errorNotifier: (message: string) => this.notifier.error(message),
592 } catch (err) { 627
593 logger.error('Cannot dispose player.', err) 628 peertubeLink: () => false,
629
630 pluginsManager: this.pluginService.getPluginsManager()
594 } 631 }
595 } 632 }
596 633
597 private buildPlayerManagerOptions (params: { 634 private buildPeerTubePlayerLoadOptions (options: {
598 video: VideoDetails 635 video: VideoDetails
599 liveVideo: LiveVideo 636 liveVideo: LiveVideo
600 videoCaptions: VideoCaption[] 637 videoCaptions: VideoCaption[]
638 storyboards: Storyboard[]
601 639
602 videoFileToken: string 640 videoFileToken: string
641 videoPassword: string
603 642
604 urlOptions: CustomizationOptions & { playerMode: PlayerMode } 643 urlOptions: URLOptions
605 644
606 loggedInOrAnonymousUser: User 645 loggedInOrAnonymousUser: User
607 forceAutoplay: boolean 646 forceAutoplay: boolean
608 user?: AuthUser // Keep for plugins 647 user?: AuthUser // Keep for plugins
609 }) { 648 }): PeerTubePlayerLoadOptions {
610 const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params 649 const {
650 video,
651 liveVideo,
652 videoCaptions,
653 storyboards,
654 videoFileToken,
655 videoPassword,
656 urlOptions,
657 loggedInOrAnonymousUser,
658 forceAutoplay
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 }
611 683
612 const getStartTime = () => { 684 const getStartTime = () => {
613 const byUrl = urlOptions.startTime !== undefined 685 const byUrl = urlOptions.startTime !== undefined
@@ -634,117 +706,93 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
634 src: environment.apiUrl + c.captionPath 706 src: environment.apiUrl + c.captionPath
635 })) 707 }))
636 708
709 const storyboard = storyboards.length !== 0
710 ? {
711 url: environment.apiUrl + storyboards[0].storyboardPath,
712 height: storyboards[0].spriteHeight,
713 width: storyboards[0].spriteWidth,
714 interval: storyboards[0].spriteDuration
715 }
716 : undefined
717
637 const liveOptions = video.isLive 718 const liveOptions = video.isLive
638 ? { latencyMode: liveVideo.latencyMode } 719 ? { latencyMode: liveVideo.latencyMode }
639 : undefined 720 : undefined
640 721
641 const options: PeertubePlayerManagerOptions = { 722 return {
642 common: { 723 mode,
643 autoplay: this.isAutoplay(),
644 forceAutoplay,
645 p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
646
647 hasNextVideo: () => this.hasNextVideo(),
648 nextVideo: () => this.playNextVideoInAngularZone(),
649
650 playerElement: this.playerElement,
651 onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
652
653 videoDuration: video.duration,
654 enableHotkeys: true,
655 inactivityTimeout: 2500,
656 poster: video.previewUrl,
657
658 startTime,
659 stopTime: urlOptions.stopTime,
660 controlBar: urlOptions.controlBar,
661 controls: urlOptions.controls,
662 muted: urlOptions.muted,
663 loop: urlOptions.loop,
664 subtitle: urlOptions.subtitle,
665 playbackRate: urlOptions.playbackRate,
666 724
667 peertubeLink: urlOptions.peertubeLink, 725 autoplay: this.isAutoplay(),
726 forceAutoplay,
668 727
669 theaterButton: true, 728 duration: this.video.duration,
670 captions: videoCaptions.length !== 0, 729 poster: video.previewUrl,
730 p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
671 731
672 embedUrl: video.embedUrl, 732 startTime,
673 embedTitle: video.name, 733 stopTime: urlOptions.stopTime,
674 instanceName: this.serverConfig.instance.name,
675 734
676 isLive: video.isLive, 735 embedUrl: video.embedUrl,
677 liveOptions, 736 embedTitle: video.name,
678 737
679 language: this.localeId, 738 isLive: video.isLive,
739 liveOptions,
680 740
681 metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', 741 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
742 ? this.videoService.getVideoViewUrl(video.uuid)
743 : null,
682 744
683 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE 745 videoFileToken: () => videoFileToken,
684 ? this.videoService.getVideoViewUrl(video.uuid) 746 requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
685 : null, 747 requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
686 videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS, 748 !video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
687 authorizationHeader: () => this.authService.getRequestHeaderValue(), 749 videoPassword: () => videoPassword,
688 750
689 serverUrl: environment.originServerUrl || window.location.origin, 751 videoCaptions: playerCaptions,
752 storyboard,
690 753
691 videoFileToken: () => videoFileToken, 754 videoShortUUID: video.shortUUID,
692 requiresAuth: videoRequiresAuth(video), 755 videoUUID: video.uuid,
693 756
694 videoCaptions: playerCaptions, 757 previousVideo: {
758 enabled: this.playlist && this.videoWatchPlaylist.hasPreviousVideo(),
695 759
696 videoShortUUID: video.shortUUID, 760 handler: this.playlist
697 videoUUID: video.uuid, 761 ? () => this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
762 : undefined,
698 763
699 errorNotifier: (message: string) => this.notifier.error(message) 764 displayControlBarButton: !!this.playlist
700 }, 765 },
701 766
702 webtorrent: { 767 nextVideo: {
703 videoFiles: video.files 768 enabled: this.hasNextVideo(),
769 handler: () => this.playNextVideoInAngularZone(),
770 getVideoTitle: () => this.getNextVideoTitle(),
771 displayControlBarButton: this.hasNextVideo()
704 }, 772 },
705 773
706 pluginsManager: this.pluginService.getPluginsManager() 774 upnext: {
707 } 775 isEnabled: () => {
776 if (this.playlist) return this.isPlaylistAutoPlayNext()
708 777
709 // Only set this if we're in a playlist 778 return this.isAutoPlayNext()
710 if (this.playlist) { 779 },
711 options.common.hasPreviousVideo = () => this.videoWatchPlaylist.hasPreviousVideo()
712
713 options.common.previousVideo = () => {
714 this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
715 }
716 }
717
718 let mode: PlayerMode
719
720 if (urlOptions.playerMode) {
721 if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
722 else mode = 'webtorrent'
723 } else {
724 if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
725 else mode = 'webtorrent'
726 }
727 780
728 // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available 781 isSuspended: (player: videojs.Player) => {
729 if (typeof TextEncoder === 'undefined') { 782 return !isXPercentInViewport(player.el() as HTMLElement, 80)
730 mode = 'webtorrent' 783 },
731 }
732 784
733 if (mode === 'p2p-media-loader') { 785 timeout: this.playlist
734 const hlsPlaylist = video.getHlsPlaylist() 786 ? 0 // Don't wait to play next video in playlist
787 : 5000 // 5 seconds for a recommended video
788 },
735 789
736 const p2pMediaLoader = { 790 hls: hlsOptions,
737 playlistUrl: hlsPlaylist.playlistUrl,
738 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
739 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
740 trackerAnnounce: video.trackerUrls,
741 videoFiles: hlsPlaylist.files
742 } as P2PMediaLoaderOptions
743 791
744 Object.assign(options, { p2pMediaLoader }) 792 webVideo: {
793 videoFiles: video.files
794 }
745 } 795 }
746
747 return { playerMode: mode, playerOptions: options }
748 } 796 }
749 797
750 private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) { 798 private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
@@ -792,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
792 this.video.viewers = newViewers 840 this.video.viewers = newViewers
793 } 841 }
794 842
843 private updatePlayerOnNoLive () {
844 this.peertubePlayer.unload()
845 this.peertubePlayer.disable()
846 this.peertubePlayer.setPoster(this.video.previewPath)
847 }
848
795 private buildHotkeysHelp (video: Video) { 849 private buildHotkeysHelp (video: Video) {
796 if (this.hotkeys.length !== 0) { 850 if (this.hotkeys.length !== 0) {
797 this.hotkeysService.remove(this.hotkeys) 851 this.hotkeysService.remove(this.hotkeys)
@@ -863,4 +917,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
863 this.metaService.setTag('og:url', window.location.href) 917 this.metaService.setTag('og:url', window.location.href)
864 this.metaService.setTag('url', window.location.href) 918 this.metaService.setTag('url', window.location.href)
865 } 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 }
866} 942}
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 7e4fac730..9339865f1 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -12,13 +12,14 @@ import { CoreModule, PluginService, RedirectService, ServerService } from './cor
12import { EmptyComponent } from './empty.component' 12import { EmptyComponent } from './empty.component'
13import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header' 13import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
14import { HighlightPipe } from './header/highlight.pipe' 14import { HighlightPipe } from './header/highlight.pipe'
15import { polyfillICU } from './helpers'
15import { LanguageChooserComponent, MenuComponent, NotificationComponent } from './menu' 16import { LanguageChooserComponent, MenuComponent, NotificationComponent } from './menu'
17import { AccountSetupWarningModalComponent } from './modal/account-setup-warning-modal.component'
18import { AdminWelcomeModalComponent } from './modal/admin-welcome-modal.component'
16import { ConfirmComponent } from './modal/confirm.component' 19import { ConfirmComponent } from './modal/confirm.component'
17import { CustomModalComponent } from './modal/custom-modal.component' 20import { CustomModalComponent } from './modal/custom-modal.component'
18import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component' 21import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component'
19import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component' 22import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component'
20import { AdminWelcomeModalComponent } from './modal/admin-welcome-modal.component'
21import { AccountSetupWarningModalComponent } from './modal/account-setup-warning-modal.component'
22import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module' 23import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module'
23import { SharedFormModule } from './shared/shared-forms' 24import { SharedFormModule } from './shared/shared-forms'
24import { SharedGlobalIconModule } from './shared/shared-icons' 25import { SharedGlobalIconModule } from './shared/shared-icons'
@@ -90,6 +91,11 @@ export function loadConfigFactory (server: ServerService, pluginService: PluginS
90 useFactory: loadConfigFactory, 91 useFactory: loadConfigFactory,
91 deps: [ ServerService, PluginService, RedirectService ], 92 deps: [ ServerService, PluginService, RedirectService ],
92 multi: true 93 multi: true
94 },
95 {
96 provide: APP_INITIALIZER,
97 useFactory: () => polyfillICU,
98 multi: true
93 } 99 }
94 ] 100 ]
95}) 101})
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts
index 89a25f0a5..abe163aae 100644
--- a/client/src/app/core/confirm/confirm.service.ts
+++ b/client/src/app/core/confirm/confirm.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4type ConfirmOptions = { 4type ConfirmOptions = {
5 title: string 5 title: string
6 message: string 6 message: string
7 errorMessage?: string
7} & ( 8} & (
8 { 9 {
9 type: 'confirm' 10 type: 'confirm'
@@ -12,6 +13,7 @@ type ConfirmOptions = {
12 { 13 {
13 type: 'confirm-password' 14 type: 'confirm-password'
14 confirmButtonText?: string 15 confirmButtonText?: string
16 isIncorrectPassword?: boolean
15 } | 17 } |
16 { 18 {
17 type: 'confirm-expected-input' 19 type: 'confirm-expected-input'
@@ -32,8 +34,14 @@ export class ConfirmService {
32 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) 34 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
33 } 35 }
34 36
35 confirmWithPassword (message: string, title = '', confirmButtonText?: string) { 37 confirmWithPassword (options: {
36 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) 38 message: string
39 title?: string
40 confirmButtonText?: string
41 errorMessage?: string
42 }) {
43 const { message, title = '', confirmButtonText, errorMessage } = options
44 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage })
37 45
38 const obs = this.confirmResponse.asObservable() 46 const obs = this.confirmResponse.asObservable()
39 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) 47 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index d57608f1c..5aa02e472 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -30,8 +30,6 @@ export class User implements UserServerModel {
30 autoPlayNextVideoPlaylist: boolean 30 autoPlayNextVideoPlaylist: boolean
31 31
32 p2pEnabled: boolean 32 p2pEnabled: boolean
33 // FIXME: deprecated in 4.1
34 webTorrentEnabled: never
35 33
36 videosHistoryEnabled: boolean 34 videosHistoryEnabled: boolean
37 videoLanguages: string[] 35 videoLanguages: string[]
diff --git a/client/src/app/helpers/i18n-utils.ts b/client/src/app/helpers/i18n-utils.ts
index b7d73d16b..9e22bb4c1 100644
--- a/client/src/app/helpers/i18n-utils.ts
+++ b/client/src/app/helpers/i18n-utils.ts
@@ -1,4 +1,6 @@
1import IntlMessageFormat from 'intl-messageformat' 1import IntlMessageFormat from 'intl-messageformat'
2import { shouldPolyfill as shouldPolyfillLocale } from '@formatjs/intl-locale/should-polyfill'
3import { shouldPolyfill as shouldPolyfillPlural } from '@formatjs/intl-pluralrules/should-polyfill'
2import { logger } from '@root-helpers/logger' 4import { logger } from '@root-helpers/logger'
3import { environment } from '../../environments/environment' 5import { environment } from '../../environments/environment'
4 6
@@ -10,31 +12,68 @@ function getDevLocale () {
10 return 'fr-FR' 12 return 'fr-FR'
11} 13}
12 14
13function prepareIcu (icu: string) { 15async function polyfillICU () {
14 let alreadyWarned = false 16 // Important to be in this order, Plural needs Locale (https://formatjs.io/docs/polyfills/intl-pluralrules)
17 await polyfillICULocale()
18 await polyfillICUPlural()
19}
15 20
16 try { 21async function polyfillICULocale () {
17 const msg = new IntlMessageFormat(icu, $localize.locale) 22 // This locale is supported
23 if (shouldPolyfillLocale()) {
24 // TODO: remove, it's only needed to support Plural polyfill and so iOS 12
25 console.log('Loading Intl Locale polyfill for ' + $localize.locale)
26
27 await import('@formatjs/intl-locale/polyfill')
28 }
29}
30
31async function polyfillICUPlural () {
32 const unsupportedLocale = shouldPolyfillPlural($localize.locale)
33
34 // This locale is supported
35 if (!unsupportedLocale) {
36 return
37 }
18 38
19 return (context: { [id: string]: number | string }, fallback: string) => { 39 // TODO: remove, it's only needed to support iOS 12
20 try { 40 console.log('Loading Intl Plural rules polyfill for ' + $localize.locale)
21 return msg.format(context) as string
22 } catch (err) {
23 if (!alreadyWarned) logger.warn(`Cannot format ICU ${icu}.`, err)
24 41
25 alreadyWarned = true 42 // Load the polyfill 1st BEFORE loading data
26 return fallback 43 await import('@formatjs/intl-pluralrules/polyfill-force')
27 } 44 // Degraded mode, so only load the en local data
45 await import(`@formatjs/intl-pluralrules/locale-data/en.js`)
46}
47
48// ---------------------------------------------------------------------------
49
50const icuCache = new Map<string, IntlMessageFormat>()
51const icuWarnings = new Set<string>()
52const fallback = 'String translation error'
53
54function formatICU (icu: string, context: { [id: string]: number | string }) {
55 try {
56 let msg = icuCache.get(icu)
57
58 if (!msg) {
59 msg = new IntlMessageFormat(icu, $localize.locale)
60 icuCache.set(icu, msg)
28 } 61 }
62
63 return msg.format(context) as string
29 } catch (err) { 64 } catch (err) {
30 logger.warn(`Cannot build intl message ${icu}.`, err) 65 if (!icuWarnings.has(icu)) {
66 logger.warn(`Cannot format ICU ${icu}.`, err)
67 }
31 68
32 return (_context: unknown, fallback: string) => fallback 69 icuWarnings.add(icu)
70 return fallback
33 } 71 }
34} 72}
35 73
36export { 74export {
37 getDevLocale, 75 getDevLocale,
38 prepareIcu, 76 polyfillICU,
77 formatICU,
39 isOnDevLocale 78 isOnDevLocale
40} 79}
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/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
index 6584db3e6..33696d0a5 100644
--- a/client/src/app/modal/confirm.component.html
+++ b/client/src/app/modal/confirm.component.html
@@ -12,10 +12,12 @@
12 <div *ngIf="inputLabel" class="form-group mt-3"> 12 <div *ngIf="inputLabel" class="form-group mt-3">
13 <label for="confirmInput">{{ inputLabel }}</label> 13 <label for="confirmInput">{{ inputLabel }}</label>
14 14
15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" /> 15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()" />
16 16
17 <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text> 17 <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()"></my-input-text>
18 </div> 18 </div>
19
20 <div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div>
19 </div> 21 </div>
20 22
21 <div class="modal-footer inputs"> 23 <div class="modal-footer inputs">
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
index 3bb8b9b21..43369befa 100644
--- a/client/src/app/modal/confirm.component.ts
+++ b/client/src/app/modal/confirm.component.ts
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
21 inputValue = '' 21 inputValue = ''
22 confirmButtonText = '' 22 confirmButtonText = ''
23 23
24 errorMessage = ''
25
24 isPasswordInput = false 26 isPasswordInput = false
25 27
26 private openedModal: NgbModalRef 28 private openedModal: NgbModalRef
@@ -42,8 +44,9 @@ export class ConfirmComponent implements OnInit {
42 this.inputValue = '' 44 this.inputValue = ''
43 this.confirmButtonText = '' 45 this.confirmButtonText = ''
44 this.isPasswordInput = false 46 this.isPasswordInput = false
47 this.errorMessage = ''
45 48
46 const { type, title, message, confirmButtonText } = payload 49 const { type, title, message, confirmButtonText, errorMessage } = payload
47 50
48 this.title = title 51 this.title = title
49 52
@@ -53,6 +56,7 @@ export class ConfirmComponent implements OnInit {
53 } else if (type === 'confirm-password') { 56 } else if (type === 'confirm-password') {
54 this.inputLabel = $localize`Confirm your password` 57 this.inputLabel = $localize`Confirm your password`
55 this.isPasswordInput = true 58 this.isPasswordInput = true
59 this.errorMessage = errorMessage
56 } 60 }
57 61
58 this.confirmButtonText = confirmButtonText || $localize`Confirm` 62 this.confirmButtonText = confirmButtonText || $localize`Confirm`
@@ -78,6 +82,9 @@ export class ConfirmComponent implements OnInit {
78 return this.expectedInputValue !== this.inputValue 82 return this.expectedInputValue !== this.inputValue
79 } 83 }
80 84
85 hasError () {
86 return this.errorMessage
87 }
81 showModal () { 88 showModal () {
82 this.inputValue = '' 89 this.inputValue = ''
83 90
diff --git a/client/src/app/shared/form-validators/custom-config-validators.ts b/client/src/app/shared/form-validators/custom-config-validators.ts
index ff0813f7d..3672e5610 100644
--- a/client/src/app/shared/form-validators/custom-config-validators.ts
+++ b/client/src/app/shared/form-validators/custom-config-validators.ts
@@ -22,21 +22,12 @@ export const SERVICES_TWITTER_USERNAME_VALIDATOR: BuildFormValidator = {
22 } 22 }
23} 23}
24 24
25export const CACHE_PREVIEWS_SIZE_VALIDATOR: BuildFormValidator = { 25export const CACHE_SIZE_VALIDATOR: BuildFormValidator = {
26 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 26 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
27 MESSAGES: { 27 MESSAGES: {
28 required: $localize`Previews cache size is required.`, 28 required: $localize`Cache size is required.`,
29 min: $localize`Previews cache size must be greater than 1.`, 29 min: $localize`Cache size must be greater than 1.`,
30 pattern: $localize`Previews cache size must be a number.` 30 pattern: $localize`Cache size must be a number.`
31 }
32}
33
34export const CACHE_CAPTIONS_SIZE_VALIDATOR: BuildFormValidator = {
35 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
36 MESSAGES: {
37 required: $localize`Captions cache size is required.`,
38 min: $localize`Captions cache size must be greater than 1.`,
39 pattern: $localize`Captions cache size must be a number.`
40 } 31 }
41} 32}
42 33
diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts
index a4bda8f16..090a76e43 100644
--- a/client/src/app/shared/form-validators/video-validators.ts
+++ b/client/src/app/shared/form-validators/video-validators.ts
@@ -26,6 +26,15 @@ export const VIDEO_PRIVACY_VALIDATOR: BuildFormValidator = {
26 } 26 }
27} 27}
28 28
29export const VIDEO_PASSWORD_VALIDATOR: BuildFormValidator = {
30 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
31 MESSAGES: {
32 minLength: $localize`A password should be at least 2 characters long.`,
33 maxLength: $localize`A password should be shorter than 100 characters long.`,
34 required: $localize`A password is required for password protected video.`
35 }
36}
37
29export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = { 38export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = {
30 VALIDATORS: [ ], 39 VALIDATORS: [ ],
31 MESSAGES: {} 40 MESSAGES: {}
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
index 2c3226f68..8b6cd091a 100644
--- a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
+++ b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
@@ -1,7 +1,7 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { SelectOptionsItem } from '../../../../types/select-options-item.model' 5import { SelectOptionsItem } from '../../../../types/select-options-item.model'
6import { ItemSelectCheckboxValue } from './select-checkbox.component' 6import { ItemSelectCheckboxValue } from './select-checkbox.component'
7 7
@@ -80,9 +80,9 @@ export class SelectCheckboxAllComponent implements ControlValueAccessor {
80 80
81 if (outputItems.length >= this.maxItems) { 81 if (outputItems.length >= this.maxItems) {
82 this.notifier.error( 82 this.notifier.error(
83 prepareIcu($localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`)( 83 formatICU(
84 { maxItems: this.maxItems }, 84 $localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`,
85 $localize`You can't select more than ${this.maxItems} items` 85 { maxItems: this.maxItems }
86 ) 86 )
87 ) 87 )
88 88
diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts
index 2e63f6c17..ab1b1458a 100644
--- a/client/src/app/shared/shared-instance/instance-features-table.component.ts
+++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts
@@ -1,6 +1,6 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ServerService } from '@app/core' 2import { ServerService } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4import { ServerConfig } from '@shared/models' 4import { ServerConfig } from '@shared/models'
5 5
6@Component({ 6@Component({
@@ -71,17 +71,17 @@ export class InstanceFeaturesTableComponent implements OnInit {
71 const hours = Math.floor(seconds / 3600) 71 const hours = Math.floor(seconds / 3600)
72 72
73 if (hours !== 0) { 73 if (hours !== 0) {
74 return prepareIcu($localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`)( 74 return formatICU(
75 { hours }, 75 $localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`,
76 $localize`~ ${hours} hours` 76 { hours }
77 ) 77 )
78 } 78 }
79 79
80 const minutes = Math.floor(seconds % 3600 / 60) 80 const minutes = Math.floor(seconds % 3600 / 60)
81 81
82 return prepareIcu($localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`)( 82 return formatICU(
83 { minutes }, 83 $localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`,
84 $localize`~ ${minutes} minutes` 84 { minutes }
85 ) 85 )
86 } 86 }
87 87
diff --git a/client/src/app/shared/shared-main/angular/from-now.pipe.ts b/client/src/app/shared/shared-main/angular/from-now.pipe.ts
index dc6a25e83..4ff244bbb 100644
--- a/client/src/app/shared/shared-main/angular/from-now.pipe.ts
+++ b/client/src/app/shared/shared-main/angular/from-now.pipe.ts
@@ -1,14 +1,9 @@
1import { Pipe, PipeTransform } from '@angular/core' 1import { Pipe, PipeTransform } from '@angular/core'
2import { prepareIcu } from '@app/helpers' 2import { formatICU } from '@app/helpers'
3 3
4// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site 4// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
5@Pipe({ name: 'myFromNow' }) 5@Pipe({ name: 'myFromNow' })
6export class FromNowPipe implements PipeTransform { 6export class FromNowPipe implements PipeTransform {
7 private yearICU = prepareIcu($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`)
8 private monthICU = prepareIcu($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`)
9 private weekICU = prepareIcu($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`)
10 private dayICU = prepareIcu($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`)
11 private hourICU = prepareIcu($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`)
12 7
13 transform (arg: number | Date | string) { 8 transform (arg: number | Date | string) {
14 const argDate = new Date(arg) 9 const argDate = new Date(arg)
@@ -16,7 +11,7 @@ export class FromNowPipe implements PipeTransform {
16 11
17 let interval = Math.floor(seconds / 31536000) 12 let interval = Math.floor(seconds / 31536000)
18 if (interval >= 1) { 13 if (interval >= 1) {
19 return this.yearICU({ interval }, $localize`${interval} year(s) ago`) 14 return formatICU($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`, { interval })
20 } 15 }
21 16
22 interval = Math.floor(seconds / 2419200) 17 interval = Math.floor(seconds / 2419200)
@@ -25,7 +20,7 @@ export class FromNowPipe implements PipeTransform {
25 if (interval >= 12) return $localize`1 year ago` 20 if (interval >= 12) return $localize`1 year ago`
26 21
27 if (interval >= 1) { 22 if (interval >= 1) {
28 return this.monthICU({ interval }, $localize`${interval} month(s) ago`) 23 return formatICU($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`, { interval })
29 } 24 }
30 25
31 interval = Math.floor(seconds / 604800) 26 interval = Math.floor(seconds / 604800)
@@ -34,17 +29,17 @@ export class FromNowPipe implements PipeTransform {
34 if (interval >= 4) return $localize`1 month ago` 29 if (interval >= 4) return $localize`1 month ago`
35 30
36 if (interval >= 1) { 31 if (interval >= 1) {
37 return this.weekICU({ interval }, $localize`${interval} week(s) ago`) 32 return formatICU($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`, { interval })
38 } 33 }
39 34
40 interval = Math.floor(seconds / 86400) 35 interval = Math.floor(seconds / 86400)
41 if (interval >= 1) { 36 if (interval >= 1) {
42 return this.dayICU({ interval }, $localize`${interval} day(s) ago`) 37 return formatICU($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`, { interval })
43 } 38 }
44 39
45 interval = Math.floor(seconds / 3600) 40 interval = Math.floor(seconds / 3600)
46 if (interval >= 1) { 41 if (interval >= 1) {
47 return this.hourICU({ interval }, $localize`${interval} hour(s) ago`) 42 return formatICU($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`, { interval })
48 } 43 }
49 44
50 interval = Math.floor(seconds / 60) 45 interval = Math.floor(seconds / 60)
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index d3ec31d6e..480277450 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -52,6 +52,7 @@ import {
52 VideoFileTokenService, 52 VideoFileTokenService,
53 VideoImportService, 53 VideoImportService,
54 VideoOwnershipService, 54 VideoOwnershipService,
55 VideoPasswordService,
55 VideoResolver, 56 VideoResolver,
56 VideoService 57 VideoService
57} from './video' 58} from './video'
@@ -210,6 +211,8 @@ import { VideoChannelService } from './video-channel'
210 211
211 VideoChannelService, 212 VideoChannelService,
212 213
214 VideoPasswordService,
215
213 CustomPageService, 216 CustomPageService,
214 217
215 ActorRedirectGuard 218 ActorRedirectGuard
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
index 0f3afd116..21f31a717 100644
--- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
+++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
@@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor, ServerService } from '@app/core' 5import { RestExtractor, ServerService } from '@app/core'
6import { objectToFormData, sortBy } from '@app/helpers' 6import { objectToFormData, sortBy } from '@app/helpers'
7import { VideoService } from '@app/shared/shared-main/video' 7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate } from '@shared/core-utils/i18n' 8import { peertubeTranslate } from '@shared/core-utils/i18n'
9import { ResultList, VideoCaption } from '@shared/models' 9import { ResultList, VideoCaption } from '@shared/models'
10import { environment } from '../../../../environments/environment' 10import { environment } from '../../../../environments/environment'
@@ -18,8 +18,10 @@ export class VideoCaptionService {
18 private restExtractor: RestExtractor 18 private restExtractor: RestExtractor
19 ) {} 19 ) {}
20 20
21 listCaptions (videoId: string): Observable<ResultList<VideoCaption>> { 21 listCaptions (videoId: string, videoPassword?: string): Observable<ResultList<VideoCaption>> {
22 return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`) 22 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
23
24 return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`, { headers })
23 .pipe( 25 .pipe(
24 switchMap(captionsResult => { 26 switchMap(captionsResult => {
25 return this.serverService.getServerLocale() 27 return this.serverService.getServerLocale()
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
index a2e47883e..07d40b117 100644
--- a/client/src/app/shared/shared-main/video/index.ts
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -5,6 +5,7 @@ export * from './video-edit.model'
5export * from './video-file-token.service' 5export * from './video-file-token.service'
6export * from './video-import.service' 6export * from './video-import.service'
7export * from './video-ownership.service' 7export * from './video-ownership.service'
8export * from './video-password.service'
8export * from './video.model' 9export * from './video.model'
9export * from './video.resolver' 10export * from './video.resolver'
10export * from './video.service' 11export * from './video.service'
diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts
index 47eee80d8..1b8b67ee2 100644
--- a/client/src/app/shared/shared-main/video/video-edit.model.ts
+++ b/client/src/app/shared/shared-main/video/video-edit.model.ts
@@ -1,5 +1,5 @@
1import { getAbsoluteAPIUrl } from '@app/helpers' 1import { getAbsoluteAPIUrl } from '@app/helpers'
2import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' 2import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
3import { VideoDetails } from './video-details.model' 3import { VideoDetails } from './video-details.model'
4import { objectKeysTyped } from '@shared/core-utils' 4import { objectKeysTyped } from '@shared/core-utils'
5 5
@@ -18,6 +18,7 @@ export class VideoEdit implements VideoUpdate {
18 waitTranscoding: boolean 18 waitTranscoding: boolean
19 channelId: number 19 channelId: number
20 privacy: VideoPrivacy 20 privacy: VideoPrivacy
21 videoPassword?: string
21 support: string 22 support: string
22 thumbnailfile?: any 23 thumbnailfile?: any
23 previewfile?: any 24 previewfile?: any
@@ -32,7 +33,7 @@ export class VideoEdit implements VideoUpdate {
32 33
33 pluginData?: any 34 pluginData?: any
34 35
35 constructor (video?: VideoDetails) { 36 constructor (video?: VideoDetails, videoPassword?: VideoPassword) {
36 if (!video) return 37 if (!video) return
37 38
38 this.id = video.id 39 this.id = video.id
@@ -63,6 +64,8 @@ export class VideoEdit implements VideoUpdate {
63 : null 64 : null
64 65
65 this.pluginData = video.pluginData 66 this.pluginData = video.pluginData
67
68 if (videoPassword) this.videoPassword = videoPassword.password
66 } 69 }
67 70
68 patch (values: { [ id: string ]: any }) { 71 patch (values: { [ id: string ]: any }) {
@@ -112,6 +115,7 @@ export class VideoEdit implements VideoUpdate {
112 waitTranscoding: this.waitTranscoding, 115 waitTranscoding: this.waitTranscoding,
113 channelId: this.channelId, 116 channelId: this.channelId,
114 privacy: this.privacy, 117 privacy: this.privacy,
118 videoPassword: this.videoPassword,
115 originallyPublishedAt: this.originallyPublishedAt 119 originallyPublishedAt: this.originallyPublishedAt
116 } 120 }
117 121
diff --git a/client/src/app/shared/shared-main/video/video-file-token.service.ts b/client/src/app/shared/shared-main/video/video-file-token.service.ts
index 791607249..9bca5b9ec 100644
--- a/client/src/app/shared/shared-main/video/video-file-token.service.ts
+++ b/client/src/app/shared/shared-main/video/video-file-token.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core' 4import { RestExtractor } from '@app/core'
5import { VideoToken } from '@shared/models' 5import { VideoToken } from '@shared/models'
6import { VideoService } from './video.service' 6import { VideoService } from './video.service'
7import { VideoPasswordService } from './video-password.service'
7 8
8@Injectable() 9@Injectable()
9export class VideoFileTokenService { 10export class VideoFileTokenService {
@@ -15,16 +16,18 @@ export class VideoFileTokenService {
15 private restExtractor: RestExtractor 16 private restExtractor: RestExtractor
16 ) {} 17 ) {}
17 18
18 getVideoFileToken (videoUUID: string) { 19 getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) {
19 const existing = this.store.get(videoUUID) 20 const existing = this.store.get(videoUUID)
20 if (existing) return of(existing) 21 if (existing) return of(existing)
21 22
22 return this.createVideoFileToken(videoUUID) 23 return this.createVideoFileToken(videoUUID, videoPassword)
23 .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) }))) 24 .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
24 } 25 }
25 26
26 private createVideoFileToken (videoUUID: string) { 27 private createVideoFileToken (videoUUID: string, videoPassword?: string) {
27 return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}) 28 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
29
30 return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers })
28 .pipe( 31 .pipe(
29 map(({ files }) => files), 32 map(({ files }) => files),
30 catchError(err => this.restExtractor.handleError(err)) 33 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/shared-main/video/video-password.service.ts b/client/src/app/shared/shared-main/video/video-password.service.ts
new file mode 100644
index 000000000..d5b0406f8
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-password.service.ts
@@ -0,0 +1,29 @@
1import { ResultList, VideoPassword } from '@shared/models'
2import { Injectable } from '@angular/core'
3import { catchError, switchMap } from 'rxjs'
4import { HttpClient, HttpHeaders } from '@angular/common/http'
5import { RestExtractor } from '@app/core'
6import { VideoService } from './video.service'
7
8@Injectable()
9export class VideoPasswordService {
10
11 constructor (
12 private authHttp: HttpClient,
13 private restExtractor: RestExtractor
14 ) {}
15
16 static buildVideoPasswordHeader (videoPassword: string) {
17 return videoPassword
18 ? new HttpHeaders().set('x-peertube-video-password', videoPassword)
19 : undefined
20 }
21
22 getVideoPasswords (options: { videoUUID: string }) {
23 return this.authHttp.get<ResultList<VideoPassword>>(`${VideoService.BASE_VIDEO_URL}/${options.videoUUID}/passwords`)
24 .pipe(
25 switchMap(res => res.data),
26 catchError(err => this.restExtractor.handleError(err))
27 )
28 }
29}
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
index 6fdffb394..1ffc40411 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -1,6 +1,6 @@
1import { AuthUser } from '@app/core' 1import { AuthUser } from '@app/core'
2import { User } from '@app/core/users/user.model' 2import { User } from '@app/core/users/user.model'
3import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl, prepareIcu } from '@app/helpers' 3import { durationToString, formatICU, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
4import { Actor } from '@app/shared/shared-main/account/actor.model' 4import { Actor } from '@app/shared/shared-main/account/actor.model'
5import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils' 5import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils'
6import { peertubeTranslate } from '@shared/core-utils/i18n' 6import { peertubeTranslate } from '@shared/core-utils/i18n'
@@ -19,9 +19,6 @@ import {
19} from '@shared/models' 19} from '@shared/models'
20 20
21export class Video implements VideoServerModel { 21export class Video implements VideoServerModel {
22 private static readonly viewsICU = prepareIcu($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`)
23 private static readonly viewersICU = prepareIcu($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`)
24
25 byVideoChannel: string 22 byVideoChannel: string
26 byAccount: string 23 byAccount: string
27 24
@@ -255,7 +252,7 @@ export class Video implements VideoServerModel {
255 user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) && 252 user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
256 this.state.id !== VideoState.TO_TRANSCODE && 253 this.state.id !== VideoState.TO_TRANSCODE &&
257 this.hasHLS() && 254 this.hasHLS() &&
258 this.hasWebTorrent() 255 this.hasWebVideos()
259 } 256 }
260 257
261 canRunTranscoding (user: AuthUser) { 258 canRunTranscoding (user: AuthUser) {
@@ -268,7 +265,7 @@ export class Video implements VideoServerModel {
268 return this.streamingPlaylists?.some(p => p.type === VideoStreamingPlaylistType.HLS) 265 return this.streamingPlaylists?.some(p => p.type === VideoStreamingPlaylistType.HLS)
269 } 266 }
270 267
271 hasWebTorrent () { 268 hasWebVideos () {
272 return this.files && this.files.length !== 0 269 return this.files && this.files.length !== 0
273 } 270 }
274 271
@@ -281,11 +278,18 @@ export class Video implements VideoServerModel {
281 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) 278 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
282 } 279 }
283 280
281 canAccessPasswordProtectedVideoWithoutPassword (user: AuthUser) {
282 return this.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
283 user &&
284 this.isLocal === true &&
285 (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
286 }
287
284 getExactNumberOfViews () { 288 getExactNumberOfViews () {
285 if (this.isLive) { 289 if (this.isLive) {
286 return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) 290 return formatICU($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`, { viewers: this.viewers })
287 } 291 }
288 292
289 return Video.viewsICU({ views: this.views }, $localize`{${this.views} view(s)}`) 293 return formatICU($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`, { views: this.views })
290 } 294 }
291} 295}
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index 78a49567f..20145b9c5 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -11,6 +11,7 @@ import {
11 FeedFormat, 11 FeedFormat,
12 NSFWPolicyType, 12 NSFWPolicyType,
13 ResultList, 13 ResultList,
14 Storyboard,
14 UserVideoRate, 15 UserVideoRate,
15 UserVideoRateType, 16 UserVideoRateType,
16 UserVideoRateUpdate, 17 UserVideoRateUpdate,
@@ -33,6 +34,7 @@ import { VideoChannel, VideoChannelService } from '../video-channel'
33import { VideoDetails } from './video-details.model' 34import { VideoDetails } from './video-details.model'
34import { VideoEdit } from './video-edit.model' 35import { VideoEdit } from './video-edit.model'
35import { Video } from './video.model' 36import { Video } from './video.model'
37import { VideoPasswordService } from './video-password.service'
36 38
37export type CommonVideoParams = { 39export type CommonVideoParams = {
38 videoPagination?: ComponentPaginationLight 40 videoPagination?: ComponentPaginationLight
@@ -69,16 +71,17 @@ export class VideoService {
69 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` 71 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
70 } 72 }
71 73
72 getVideo (options: { videoId: string }): Observable<VideoDetails> { 74 getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> {
73 return this.serverService.getServerLocale() 75 const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
74 .pipe( 76
75 switchMap(translations => { 77 return this.serverService.getServerLocale().pipe(
76 return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`) 78 switchMap(translations => {
77 .pipe(map(videoHash => ({ videoHash, translations }))) 79 return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`, { headers })
78 }), 80 .pipe(map(videoHash => ({ videoHash, translations })))
79 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), 81 }),
80 catchError(err => this.restExtractor.handleError(err)) 82 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
81 ) 83 catchError(err => this.restExtractor.handleError(err))
84 )
82 } 85 }
83 86
84 updateVideo (video: VideoEdit) { 87 updateVideo (video: VideoEdit) {
@@ -99,6 +102,9 @@ export class VideoService {
99 description, 102 description,
100 channelId: video.channelId, 103 channelId: video.channelId,
101 privacy: video.privacy, 104 privacy: video.privacy,
105 videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED
106 ? [ video.videoPassword ]
107 : undefined,
102 tags: video.tags, 108 tags: video.tags,
103 nsfw: video.nsfw, 109 nsfw: video.nsfw,
104 waitTranscoding: video.waitTranscoding, 110 waitTranscoding: video.waitTranscoding,
@@ -305,7 +311,7 @@ export class VideoService {
305 ) 311 )
306 } 312 }
307 313
308 removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { 314 removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'web-videos') {
309 return from(videoIds) 315 return from(videoIds)
310 .pipe( 316 .pipe(
311 concatMap(id => this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + id + '/' + type)), 317 concatMap(id => this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + id + '/' + type)),
@@ -314,12 +320,12 @@ export class VideoService {
314 ) 320 )
315 } 321 }
316 322
317 removeFile (videoId: number | string, fileId: number, type: 'hls' | 'webtorrent') { 323 removeFile (videoId: number | string, fileId: number, type: 'hls' | 'web-videos') {
318 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + videoId + '/' + type + '/' + fileId) 324 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + videoId + '/' + type + '/' + fileId)
319 .pipe(catchError(err => this.restExtractor.handleError(err))) 325 .pipe(catchError(err => this.restExtractor.handleError(err)))
320 } 326 }
321 327
322 runTranscoding (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { 328 runTranscoding (videoIds: (number | string)[], type: 'hls' | 'web-video') {
323 const body: VideoTranscodingCreate = { transcodingType: type } 329 const body: VideoTranscodingCreate = { transcodingType: type }
324 330
325 return from(videoIds) 331 return from(videoIds)
@@ -339,6 +345,27 @@ export class VideoService {
339 ) 345 )
340 } 346 }
341 347
348 // ---------------------------------------------------------------------------
349
350 getStoryboards (videoId: string | number, videoPassword: string) {
351 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
352
353 return this.authHttp
354 .get<{ storyboards: Storyboard[] }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/storyboards', { headers })
355 .pipe(
356 map(({ storyboards }) => storyboards),
357 catchError(err => {
358 if (err.status === 404) {
359 return of([])
360 }
361
362 this.restExtractor.handleError(err)
363 })
364 )
365 }
366
367 // ---------------------------------------------------------------------------
368
342 getSource (videoId: number) { 369 getSource (videoId: number) {
343 return this.authHttp 370 return this.authHttp
344 .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source') 371 .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
@@ -353,18 +380,22 @@ export class VideoService {
353 ) 380 )
354 } 381 }
355 382
356 setVideoLike (id: string) { 383 // ---------------------------------------------------------------------------
357 return this.setVideoRate(id, 'like') 384
385 setVideoLike (id: string, videoPassword: string) {
386 return this.setVideoRate(id, 'like', videoPassword)
358 } 387 }
359 388
360 setVideoDislike (id: string) { 389 setVideoDislike (id: string, videoPassword: string) {
361 return this.setVideoRate(id, 'dislike') 390 return this.setVideoRate(id, 'dislike', videoPassword)
362 } 391 }
363 392
364 unsetVideoLike (id: string) { 393 unsetVideoLike (id: string, videoPassword: string) {
365 return this.setVideoRate(id, 'none') 394 return this.setVideoRate(id, 'none', videoPassword)
366 } 395 }
367 396
397 // ---------------------------------------------------------------------------
398
368 getUserVideoRating (id: string) { 399 getUserVideoRating (id: string) {
369 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' 400 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
370 401
@@ -394,7 +425,8 @@ export class VideoService {
394 [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, 425 [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
395 [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, 426 [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
396 [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, 427 [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`,
397 [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video` 428 [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`,
429 [VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video`
398 } 430 }
399 431
400 const videoPrivacies = serverPrivacies.map(p => { 432 const videoPrivacies = serverPrivacies.map(p => {
@@ -412,7 +444,13 @@ export class VideoService {
412 } 444 }
413 445
414 getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) { 446 getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) {
415 const order = [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC ] 447 // We do not add a password as this requires additional configuration.
448 const order = [
449 VideoPrivacy.PRIVATE,
450 VideoPrivacy.INTERNAL,
451 VideoPrivacy.UNLISTED,
452 VideoPrivacy.PUBLIC
453 ]
416 454
417 for (const privacy of order) { 455 for (const privacy of order) {
418 if (serverPrivacies.find(p => p.id === privacy)) { 456 if (serverPrivacies.find(p => p.id === privacy)) {
@@ -499,14 +537,15 @@ export class VideoService {
499 } 537 }
500 } 538 }
501 539
502 private setVideoRate (id: string, rateType: UserVideoRateType) { 540 private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
503 const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate` 541 const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
504 const body: UserVideoRateUpdate = { 542 const body: UserVideoRateUpdate = {
505 rating: rateType 543 rating: rateType
506 } 544 }
545 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
507 546
508 return this.authHttp 547 return this.authHttp
509 .put(url, body) 548 .put(url, body, { headers })
510 .pipe(catchError(err => this.restExtractor.handleError(err))) 549 .pipe(catchError(err => this.restExtractor.handleError(err)))
511 } 550 }
512} 551}
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
index 27dcf043a..34295c34a 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
@@ -1,7 +1,7 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -67,9 +67,9 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
67 let message: string 67 let message: string
68 68
69 if (Array.isArray(this.usersToBan)) { 69 if (Array.isArray(this.usersToBan)) {
70 message = prepareIcu($localize`{count, plural, =1 {1 user banned.} other {{count} users banned.}}`)( 70 message = formatICU(
71 { count: this.usersToBan.length }, 71 $localize`{count, plural, =1 {1 user banned.} other {{count} users banned.}}`,
72 $localize`${this.usersToBan.length} users banned.` 72 { count: this.usersToBan.length }
73 ) 73 )
74 } else { 74 } else {
75 message = $localize`User ${this.usersToBan.username} banned.` 75 message = $localize`User ${this.usersToBan.username} banned.`
@@ -88,9 +88,9 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
88 88
89 getModalTitle () { 89 getModalTitle () {
90 if (Array.isArray(this.usersToBan)) { 90 if (Array.isArray(this.usersToBan)) {
91 return prepareIcu($localize`Ban {count, plural, =1 {1 user} other {{count} users}}`)( 91 return formatICU(
92 { count: this.usersToBan.length }, 92 $localize`Ban {count, plural, =1 {1 user} other {{count} users}}`,
93 $localize`Ban ${this.usersToBan.length} users` 93 { count: this.usersToBan.length }
94 ) 94 )
95 } 95 }
96 96
diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts
index 3ff53443a..0137def89 100644
--- a/client/src/app/shared/shared-moderation/video-block.component.ts
+++ b/client/src/app/shared/shared-moderation/video-block.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { Video } from '@app/shared/shared-main' 5import { Video } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -81,9 +81,9 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
81 this.videoBlocklistService.blockVideo(options) 81 this.videoBlocklistService.blockVideo(options)
82 .subscribe({ 82 .subscribe({
83 next: () => { 83 next: () => {
84 const message = prepareIcu($localize`{count, plural, =1 {Blocked {videoName}.} other {Blocked {count} videos.}}`)( 84 const message = formatICU(
85 { count: this.videos.length, videoName: this.getSingleVideo().name }, 85 $localize`{count, plural, =1 {Blocked {videoName}.} other {Blocked {count} videos.}}`,
86 $localize`Blocked ${this.videos.length} videos.` 86 { count: this.videos.length, videoName: this.getSingleVideo().name }
87 ) 87 )
88 88
89 this.notifier.success(message) 89 this.notifier.success(message)
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html
index 5650fa948..9f1455561 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.html
+++ b/client/src/app/shared/shared-share-modal/video-share.component.html
@@ -107,6 +107,10 @@
107 </a> 107 </a>
108 </div> 108 </div>
109 109
110 <div i18n *ngIf="isPasswordProtectedVideo()" class="alert-private alert alert-warning">
111 This video is password protected, please note that recipients will require the corresponding password to access the content.
112 </div>
113
110 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId"> 114 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
111 115
112 <ng-container ngbNavItem="url"> 116 <ng-container ngbNavItem="url">
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts
index 32f900f15..da4f2a4b4 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.ts
+++ b/client/src/app/shared/shared-share-modal/video-share.component.ts
@@ -243,6 +243,10 @@ export class VideoShareComponent {
243 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE 243 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
244 } 244 }
245 245
246 isPasswordProtectedVideo () {
247 return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
248 }
249
246 private getPlaylistOptions (baseUrl?: string) { 250 private getPlaylistOptions (baseUrl?: string) {
247 return { 251 return {
248 baseUrl, 252 baseUrl,
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index 8d2deedf7..3906652be 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -18,6 +18,7 @@ import {
18import { environment } from '../../../environments/environment' 18import { environment } from '../../../environments/environment'
19import { VideoCommentThreadTree } from './video-comment-thread-tree.model' 19import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
20import { VideoComment } from './video-comment.model' 20import { VideoComment } from './video-comment.model'
21import { VideoPasswordService } from '../shared-main'
21 22
22@Injectable() 23@Injectable()
23export class VideoCommentService { 24export class VideoCommentService {
@@ -31,22 +32,25 @@ export class VideoCommentService {
31 private restService: RestService 32 private restService: RestService
32 ) {} 33 ) {}
33 34
34 addCommentThread (videoId: string, comment: VideoCommentCreate) { 35 addCommentThread (videoId: string, comment: VideoCommentCreate, videoPassword?: string) {
36 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
35 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 37 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
36 const normalizedComment = objectLineFeedToHtml(comment, 'text') 38 const normalizedComment = objectLineFeedToHtml(comment, 'text')
37 39
38 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) 40 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
39 .pipe( 41 .pipe(
40 map(data => this.extractVideoComment(data.comment)), 42 map(data => this.extractVideoComment(data.comment)),
41 catchError(err => this.restExtractor.handleError(err)) 43 catchError(err => this.restExtractor.handleError(err))
42 ) 44 )
43 } 45 }
44 46
45 addCommentReply (videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate) { 47 addCommentReply (options: { videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate, videoPassword?: string }) {
48 const { videoId, inReplyToCommentId, comment, videoPassword } = options
49 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
46 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId 50 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
47 const normalizedComment = objectLineFeedToHtml(comment, 'text') 51 const normalizedComment = objectLineFeedToHtml(comment, 'text')
48 52
49 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) 53 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
50 .pipe( 54 .pipe(
51 map(data => this.extractVideoComment(data.comment)), 55 map(data => this.extractVideoComment(data.comment)),
52 catchError(err => this.restExtractor.handleError(err)) 56 catchError(err => this.restExtractor.handleError(err))
@@ -76,10 +80,13 @@ export class VideoCommentService {
76 80
77 getVideoCommentThreads (parameters: { 81 getVideoCommentThreads (parameters: {
78 videoId: string 82 videoId: string
83 videoPassword: string
79 componentPagination: ComponentPaginationLight 84 componentPagination: ComponentPaginationLight
80 sort: string 85 sort: string
81 }): Observable<ThreadsResultList<VideoComment>> { 86 }): Observable<ThreadsResultList<VideoComment>> {
82 const { videoId, componentPagination, sort } = parameters 87 const { videoId, videoPassword, componentPagination, sort } = parameters
88
89 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
83 90
84 const pagination = this.restService.componentToRestPagination(componentPagination) 91 const pagination = this.restService.componentToRestPagination(componentPagination)
85 92
@@ -87,7 +94,7 @@ export class VideoCommentService {
87 params = this.restService.addRestGetParams(params, pagination, sort) 94 params = this.restService.addRestGetParams(params, pagination, sort)
88 95
89 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 96 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
90 return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params }) 97 return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params, headers })
91 .pipe( 98 .pipe(
92 map(result => this.extractVideoComments(result)), 99 map(result => this.extractVideoComments(result)),
93 catchError(err => this.restExtractor.handleError(err)) 100 catchError(err => this.restExtractor.handleError(err))
@@ -97,12 +104,14 @@ export class VideoCommentService {
97 getVideoThreadComments (parameters: { 104 getVideoThreadComments (parameters: {
98 videoId: string 105 videoId: string
99 threadId: number 106 threadId: number
107 videoPassword?: string
100 }): Observable<VideoCommentThreadTree> { 108 }): Observable<VideoCommentThreadTree> {
101 const { videoId, threadId } = parameters 109 const { videoId, threadId, videoPassword } = parameters
102 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` 110 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
111 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
103 112
104 return this.authHttp 113 return this.authHttp
105 .get<VideoCommentThreadTreeServerModel>(url) 114 .get<VideoCommentThreadTreeServerModel>(url, { headers })
106 .pipe( 115 .pipe(
107 map(tree => this.extractVideoCommentTree(tree)), 116 map(tree => this.extractVideoCommentTree(tree)),
108 catchError(err => this.restExtractor.handleError(err)) 117 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index 56527ddfa..0a3ada711 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -273,7 +273,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
273 }) 273 })
274 } 274 }
275 275
276 async removeVideoFiles (video: Video, type: 'hls' | 'webtorrent') { 276 async removeVideoFiles (video: Video, type: 'hls' | 'web-videos') {
277 const confirmMessage = $localize`Do you really want to remove "${this.video.name}" files?` 277 const confirmMessage = $localize`Do you really want to remove "${this.video.name}" files?`
278 278
279 const res = await this.confirmService.confirm(confirmMessage, $localize`Remove "${this.video.name}" files`) 279 const res = await this.confirmService.confirm(confirmMessage, $localize`Remove "${this.video.name}" files`)
@@ -290,7 +290,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
290 }) 290 })
291 } 291 }
292 292
293 runTranscoding (video: Video, type: 'hls' | 'webtorrent') { 293 runTranscoding (video: Video, type: 'hls' | 'web-video') {
294 this.videoService.runTranscoding([ video.id ], type) 294 this.videoService.runTranscoding([ video.id ], type)
295 .subscribe({ 295 .subscribe({
296 next: () => { 296 next: () => {
@@ -394,8 +394,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
394 iconName: 'cog' 394 iconName: 'cog'
395 }, 395 },
396 { 396 {
397 label: $localize`Run WebTorrent transcoding`, 397 label: $localize`Run Web Video transcoding`,
398 handler: ({ video }) => this.runTranscoding(video, 'webtorrent'), 398 handler: ({ video }) => this.runTranscoding(video, 'web-video'),
399 isDisplayed: () => this.displayOptions.transcoding && this.canRunTranscoding(), 399 isDisplayed: () => this.displayOptions.transcoding && this.canRunTranscoding(),
400 iconName: 'cog' 400 iconName: 'cog'
401 }, 401 },
@@ -406,8 +406,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
406 iconName: 'delete' 406 iconName: 'delete'
407 }, 407 },
408 { 408 {
409 label: $localize`Delete WebTorrent files`, 409 label: $localize`Delete Web Video files`,
410 handler: ({ video }) => this.removeVideoFiles(video, 'webtorrent'), 410 handler: ({ video }) => this.removeVideoFiles(video, 'web-videos'),
411 isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(), 411 isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(),
412 iconName: 'delete' 412 iconName: 'delete'
413 } 413 }
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts
index cac82d8d0..146ea7dfe 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts
@@ -1,13 +1,13 @@
1import { mapValues } from 'lodash-es' 1import { mapValues } from 'lodash-es'
2import { firstValueFrom } from 'rxjs' 2import { firstValueFrom } from 'rxjs'
3import { tap } from 'rxjs/operators' 3import { tap } from 'rxjs/operators'
4import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
5import { HooksService } from '@app/core' 5import { HooksService } from '@app/core'
6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
7import { logger } from '@root-helpers/logger' 7import { logger } from '@root-helpers/logger'
8import { videoRequiresAuth } from '@root-helpers/video' 8import { videoRequiresFileToken } from '@root-helpers/video'
9import { objectKeysTyped, pick } from '@shared/core-utils' 9import { objectKeysTyped, pick } from '@shared/core-utils'
10import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' 10import { VideoCaption, VideoFile } from '@shared/models'
11import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' 11import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
12 12
13type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
@@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
21export class VideoDownloadComponent { 21export class VideoDownloadComponent {
22 @ViewChild('modal', { static: true }) modal: ElementRef 22 @ViewChild('modal', { static: true }) modal: ElementRef
23 23
24 @Input() videoPassword: string
25
24 downloadType: 'direct' | 'torrent' = 'direct' 26 downloadType: 'direct' | 'torrent' = 'direct'
25 27
26 resolutionId: number | string = -1 28 resolutionId: number | string = -1
@@ -89,8 +91,8 @@ export class VideoDownloadComponent {
89 this.subtitleLanguageId = this.videoCaptions[0].language.id 91 this.subtitleLanguageId = this.videoCaptions[0].language.id
90 } 92 }
91 93
92 if (videoRequiresAuth(this.video)) { 94 if (this.isConfidentialVideo()) {
93 this.videoFileTokenService.getVideoFileToken(this.video.uuid) 95 this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
94 .subscribe(({ token }) => this.videoFileToken = token) 96 .subscribe(({ token }) => this.videoFileToken = token)
95 } 97 }
96 98
@@ -201,7 +203,8 @@ export class VideoDownloadComponent {
201 } 203 }
202 204
203 isConfidentialVideo () { 205 isConfidentialVideo () {
204 return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 206 return videoRequiresFileToken(this.video)
207
205 } 208 }
206 209
207 switchToType (type: DownloadType) { 210 switchToType (type: DownloadType) {
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index 3d39c6fdc..3fbfaed28 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -125,7 +125,7 @@
125 <my-peertube-checkbox 125 <my-peertube-checkbox
126 formControlName="allVideos" 126 formControlName="allVideos"
127 inputName="allVideos" 127 inputName="allVideos"
128 i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" 128 i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)"
129 ></my-peertube-checkbox> 129 ></my-peertube-checkbox>
130 </div> 130 </div>
131 </div> 131 </div>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 3f0180695..9e0a4f79b 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -5,6 +5,7 @@
5 > 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> 6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container> 7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
8 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
8 </my-video-thumbnail> 9 </my-video-thumbnail>
9 10
10 <div class="video-bottom"> 11 <div class="video-bottom">
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 2384b34d7..d453f37a1 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit {
171 return this.video.privacy.id === VideoPrivacy.PRIVATE 171 return this.video.privacy.id === VideoPrivacy.PRIVATE
172 } 172 }
173 173
174 isPasswordProtectedVideo () {
175 return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
176 }
177
174 getStateLabel (video: Video) { 178 getStateLabel (video: Video) {
175 if (!video.state) return '' 179 if (!video.state) return ''
176 180
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 7b832263e..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
@@ -419,6 +419,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
419 this.lastQueryLength = data.length 419 this.lastQueryLength = data.length
420 420
421 if (reset) this.videos = [] 421 if (reset) this.videos = []
422
422 this.videos = this.videos.concat(data) 423 this.videos = this.videos.concat(data)
423 424
424 if (this.groupByDate) this.buildGroupedDateLabels() 425 if (this.groupByDate) this.buildGroupedDateLabels()
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
index 75afa0709..882b14c5e 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
@@ -21,7 +21,8 @@
21 [attr.title]="playlistElement.video.name" 21 [attr.title]="playlistElement.video.name"
22 >{{ playlistElement.video.name }}</a> 22 >{{ playlistElement.video.name }}</a>
23 23
24 <span *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span> 24 <span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
25 <span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
25 </div> 26 </div>
26 27
27 <span class="video-miniature-created-at-views"> 28 <span class="video-miniature-created-at-views">
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
index 552ea742b..b9a1d9623 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
@@ -60,6 +60,10 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
60 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE 60 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
61 } 61 }
62 62
63 isVideoPasswordProtected () {
64 return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
65 }
66
63 isUnavailable (e: VideoPlaylistElement) { 67 isUnavailable (e: VideoPlaylistElement) {
64 return e.type === VideoPlaylistElementType.UNAVAILABLE 68 return e.type === VideoPlaylistElementType.UNAVAILABLE
65 } 69 }
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 @@
1export * from './peertube-player-manager' 1export * from './peertube-player'
2export * from './types' 2export * 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 2781850b9..000000000
--- a/client/src/assets/player/peertube-player-manager.ts
+++ /dev/null
@@ -1,266 +0,0 @@
1import '@peertube/videojs-contextmenu'
2import './shared/upnext/end-card'
3import './shared/upnext/upnext-plugin'
4import './shared/stats/stats-card'
5import './shared/stats/stats-plugin'
6import './shared/bezels/bezels-plugin'
7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/next-previous-video-button'
10import './shared/control-bar/p2p-info-button'
11import './shared/control-bar/peertube-link-button'
12import './shared/control-bar/peertube-load-progress-bar'
13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
15import './shared/settings/resolution-menu-button'
16import './shared/settings/resolution-menu-item'
17import './shared/settings/settings-dialog'
18import './shared/settings/settings-menu-button'
19import './shared/settings/settings-menu-item'
20import './shared/settings/settings-panel'
21import './shared/settings/settings-panel-child'
22import './shared/playlist/playlist-plugin'
23import './shared/mobile/peertube-mobile-plugin'
24import './shared/mobile/peertube-mobile-buttons'
25import './shared/hotkeys/peertube-hotkeys-plugin'
26import './shared/metrics/metrics-plugin'
27import videojs from 'video.js'
28import { logger } from '@root-helpers/logger'
29import { PluginsManager } from '@root-helpers/plugins-manager'
30import { isMobile } from '@root-helpers/web-browser'
31import { saveAverageBandwidth } from './peertube-player-local-storage'
32import { ManagerOptionsBuilder } from './shared/manager-options'
33import { TranslationsManager } from './translations-manager'
34import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode, PlayerNetworkInfo } from './types'
35
36// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
37(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
38
39const CaptionsButton = videojs.getComponent('CaptionsButton') as any
40// Change Captions to Subtitles/CC
41CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
42// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
43CaptionsButton.prototype.label_ = ' '
44
45export class PeertubePlayerManager {
46 private static playerElementClassName: string
47 private static playerElementAttributes: { name: string, value: string }[] = []
48
49 private static onPlayerChange: (player: videojs.Player) => void
50 private static alreadyPlayed = false
51 private static pluginsManager: PluginsManager
52
53 private static videojsDecodeErrors = 0
54
55 private static p2pMediaLoaderModule: any
56
57 static initState () {
58 this.alreadyPlayed = false
59 }
60
61 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) {
62 this.pluginsManager = options.pluginsManager
63
64 this.onPlayerChange = onPlayerChange
65
66 this.playerElementClassName = options.common.playerElement.className
67
68 for (const name of options.common.playerElement.getAttributeNames()) {
69 this.playerElementAttributes.push({ name, value: options.common.playerElement.getAttribute(name) })
70 }
71
72 if (mode === 'webtorrent') await import('./shared/webtorrent/webtorrent-plugin')
73 if (mode === 'p2p-media-loader') {
74 const [ p2pMediaLoaderModule ] = await Promise.all([
75 import('@peertube/p2p-media-loader-hlsjs'),
76 import('./shared/p2p-media-loader/p2p-media-loader-plugin')
77 ])
78
79 this.p2pMediaLoaderModule = p2pMediaLoaderModule
80 }
81
82 await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
83
84 return this.buildPlayer(mode, options)
85 }
86
87 private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise<videojs.Player> {
88 const videojsOptionsBuilder = new ManagerOptionsBuilder(mode, options, this.p2pMediaLoaderModule)
89
90 const videojsOptions = await this.pluginsManager.runHook(
91 'filter:internal.player.videojs.options.result',
92 videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed)
93 )
94
95 const self = this
96 return new Promise(res => {
97 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
98 const player = this
99
100 if (!isNaN(+options.common.playbackRate)) {
101 player.playbackRate(+options.common.playbackRate)
102 }
103
104 let alreadyFallback = false
105
106 const handleError = () => {
107 if (alreadyFallback) return
108 alreadyFallback = true
109
110 if (mode === 'p2p-media-loader') {
111 self.tryToRecoverHLSError(player.error(), player, options)
112 } else {
113 self.maybeFallbackToWebTorrent(mode, player, options)
114 }
115 }
116
117 player.one('error', () => handleError())
118
119 player.one('play', () => {
120 self.alreadyPlayed = true
121 })
122
123 self.addContextMenu(videojsOptionsBuilder, player, options.common)
124
125 if (isMobile()) player.peertubeMobile()
126 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
127 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
128
129 player.bezels()
130
131 player.stats({
132 videoUUID: options.common.videoUUID,
133 videoIsLive: options.common.isLive,
134 mode,
135 p2pEnabled: options.common.p2pEnabled
136 })
137
138 player.on('p2pInfo', (_, data: PlayerNetworkInfo) => {
139 if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
140
141 saveAverageBandwidth(data.bandwidthEstimate)
142 })
143
144 const offlineNotificationElem = document.createElement('div')
145 offlineNotificationElem.classList.add('vjs-peertube-offline-notification')
146 offlineNotificationElem.innerText = player.localize('You seem to be offline and the video may not work')
147
148 let offlineNotificationElemAdded = false
149
150 const handleOnline = () => {
151 if (!offlineNotificationElemAdded) return
152
153 player.el().removeChild(offlineNotificationElem)
154 offlineNotificationElemAdded = false
155
156 logger.info('The browser is online')
157 }
158
159 const handleOffline = () => {
160 if (offlineNotificationElemAdded) return
161
162 player.el().appendChild(offlineNotificationElem)
163 offlineNotificationElemAdded = true
164
165 logger.info('The browser is offline')
166 }
167
168 window.addEventListener('online', handleOnline)
169 window.addEventListener('offline', handleOffline)
170
171 player.on('dispose', () => {
172 window.removeEventListener('online', handleOnline)
173 window.removeEventListener('offline', handleOffline)
174 })
175
176 return res(player)
177 })
178 })
179 }
180
181 private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) {
182 if (err.code === MediaError.MEDIA_ERR_DECODE) {
183
184 // Display a notification to user
185 if (this.videojsDecodeErrors === 0) {
186 options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.'))
187 }
188
189 if (this.videojsDecodeErrors === 20) {
190 this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options)
191 return
192 }
193
194 logger.info('Fast forwarding HLS to recover from an error.')
195
196 this.videojsDecodeErrors++
197
198 options.common.startTime = currentPlayer.currentTime() + 2
199 options.common.autoplay = true
200 this.rebuildAndUpdateVideoElement(currentPlayer, options.common)
201
202 const newPlayer = await this.buildPlayer('p2p-media-loader', options)
203 this.onPlayerChange(newPlayer)
204 } else {
205 this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options)
206 }
207 }
208
209 private static async maybeFallbackToWebTorrent (
210 currentMode: PlayerMode,
211 currentPlayer: videojs.Player,
212 options: PeertubePlayerManagerOptions
213 ) {
214 if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') {
215 currentPlayer.peertube().displayFatalError()
216 return
217 }
218
219 logger.info('Fallback to webtorrent.')
220
221 this.rebuildAndUpdateVideoElement(currentPlayer, options.common)
222
223 await import('./shared/webtorrent/webtorrent-plugin')
224
225 const newPlayer = await this.buildPlayer('webtorrent', options)
226 this.onPlayerChange(newPlayer)
227 }
228
229 private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) {
230 const newVideoElement = document.createElement('video')
231
232 // Reset class
233 newVideoElement.className = this.playerElementClassName
234
235 // Reapply attributes
236 for (const { name, value } of this.playerElementAttributes) {
237 newVideoElement.setAttribute(name, value)
238 }
239
240 // VideoJS wraps our video element inside a div
241 let currentParentPlayerElement = commonOptions.playerElement.parentNode
242 // Fix on IOS, don't ask me why
243 if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode
244
245 currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
246
247 commonOptions.playerElement = newVideoElement
248 commonOptions.onPlayerElementChange(newVideoElement)
249
250 player.dispose()
251
252 return newVideoElement
253 }
254
255 private static addContextMenu (optionsBuilder: ManagerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) {
256 const options = optionsBuilder.getContextMenuOptions(player, commonOptions)
257
258 player.contextmenuUI(options)
259 }
260}
261
262// ############################################################################
263
264export {
265 videojs
266}
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 @@
1import '@peertube/videojs-contextmenu'
2import './shared/upnext/end-card'
3import './shared/upnext/upnext-plugin'
4import './shared/stats/stats-card'
5import './shared/stats/stats-plugin'
6import './shared/bezels/bezels-plugin'
7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/storyboard-plugin'
10import './shared/control-bar/next-previous-video-button'
11import './shared/control-bar/p2p-info-button'
12import './shared/control-bar/peertube-link-button'
13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
15import './shared/settings/resolution-menu-button'
16import './shared/settings/resolution-menu-item'
17import './shared/settings/settings-dialog'
18import './shared/settings/settings-menu-button'
19import './shared/settings/settings-menu-item'
20import './shared/settings/settings-panel'
21import './shared/settings/settings-panel-child'
22import './shared/playlist/playlist-plugin'
23import './shared/mobile/peertube-mobile-plugin'
24import './shared/mobile/peertube-mobile-buttons'
25import './shared/hotkeys/peertube-hotkeys-plugin'
26import './shared/metrics/metrics-plugin'
27import videojs, { VideoJsPlayer } from 'video.js'
28import { logger } from '@root-helpers/logger'
29import { PluginsManager } from '@root-helpers/plugins-manager'
30import { copyToClipboard } from '@root-helpers/utils'
31import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
32import { isMobile } from '@root-helpers/web-browser'
33import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@shared/core-utils'
34import { saveAverageBandwidth } from './peertube-player-local-storage'
35import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder'
36import { TranslationsManager } from './translations-manager'
37import { 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
42const CaptionsButton = videojs.getComponent('CaptionsButton') as any
43// Change Captions to Subtitles/CC
44CaptionsButton.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)
46CaptionsButton.prototype.label_ = ' '
47
48// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged
49const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any
50if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) {
51 PlayProgressBar.prototype.options_.children.push('timeTooltip')
52}
53
54export 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
520export {
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 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import './pause-bezel' 2import { PauseBezel } from './pause-bezel'
3 3
4const Plugin = videojs.getPlugin('plugin') 4const 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
34const Component = videojs.getComponent('Component') 34const Component = videojs.getComponent('Component')
35class PauseBezel extends Component { 35export 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 e71e90713..9307027f6 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -2,5 +2,5 @@ export * from './next-previous-video-button'
2export * from './p2p-info-button' 2export * from './p2p-info-button'
3export * from './peertube-link-button' 3export * from './peertube-link-button'
4export * from './peertube-live-display' 4export * from './peertube-live-display'
5export * from './peertube-load-progress-bar' 5export * from './storyboard-plugin'
6export * from './theater-button' 6export * 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'
4const Button = videojs.getComponent('Button') 4const Button = videojs.getComponent('Button')
5 5
6class NextPreviousVideoButton extends Button { 6class 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
49videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) 58videojs.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 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' 2import { PlayerNetworkInfo } from '../../types'
3import { bytes } from '../common' 3import { bytes } from '../common'
4 4
5const Button = videojs.getComponent('Button') 5const Button = videojs.getComponent('Button')
6class P2pInfoButton extends Button { 6class 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
124videojs.registerComponent('P2PInfoButton', P2pInfoButton) 105videojs.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'
3import { PeerTubeLinkButtonOptions } from '../../types' 3import { PeerTubeLinkButtonOptions } from '../../types'
4 4
5const Component = videojs.getComponent('Component') 5const Component = videojs.getComponent('Component')
6
6class PeerTubeLinkButton extends Component { 7class 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 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class 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
33Component.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
new file mode 100644
index 000000000..80c69b5f2
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
@@ -0,0 +1,197 @@
1import videojs from 'video.js'
2import { StoryboardOptions } from '../../types'
3
4// Big thanks to this beautiful plugin: https://github.com/phloxic/videojs-sprite-thumbnails
5// Adapted to respect peertube player style
6
7const Plugin = videojs.getPlugin('plugin')
8
9class StoryboardPlugin extends Plugin {
10 private url: string
11 private height: number
12 private width: number
13 private interval: number
14
15 private cached: boolean
16
17 private mouseTimeTooltip: videojs.MouseTimeDisplay
18 private seekBar: { el(): HTMLElement, mouseTimeDisplay: any, playProgressBar: any }
19 private progress: any
20
21 private spritePlaceholder: HTMLElement
22
23 private readonly sprites: { [id: string]: HTMLImageElement } = {}
24
25 private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip
26
27 private onReadyOrLoadstartHandler: (event: { type: 'ready' }) => void
28
29 constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) {
30 super(player, options)
31
32 this.url = options.url
33 this.height = options.height
34 this.width = options.width
35 this.interval = options.interval
36
37 this.boundedHijackMouseTooltip = this.hijackMouseTooltip.bind(this)
38
39 this.init()
40
41 this.player.ready(() => {
42 player.addClass('vjs-storyboard')
43 })
44 }
45
46 init () {
47 const controls = this.player.controlBar as any
48
49 // default control bar component tree is expected
50 // https://docs.videojs.com/tutorial-components.html#default-component-tree
51 this.progress = controls?.progressControl
52 this.seekBar = this.progress?.seekBar
53
54 this.mouseTimeTooltip = this.seekBar?.mouseTimeDisplay?.timeTooltip
55
56 this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement
57 this.seekBar?.el()?.appendChild(this.spritePlaceholder)
58
59 this.onReadyOrLoadstartHandler = event => {
60 if (event.type !== 'ready') {
61 const spriteSource = this.player.currentSources().find(source => {
62 return Object.prototype.hasOwnProperty.call(source, 'storyboard')
63 }) as any
64 const spriteOpts = spriteSource?.['storyboard'] as StoryboardOptions
65
66 if (spriteOpts) {
67 this.url = spriteOpts.url
68 this.height = spriteOpts.height
69 this.width = spriteOpts.width
70 this.interval = spriteOpts.interval
71 }
72 }
73
74 this.cached = !!this.sprites[this.url]
75
76 this.load()
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()
89 }
90
91 private load () {
92 const spriteEvents = [ 'mousemove', 'touchmove' ]
93
94 if (this.isReady()) {
95 if (!this.cached) {
96 this.sprites[this.url] = videojs.dom.createEl('img', {
97 src: this.url
98 })
99 }
100 this.progress.on(spriteEvents, this.boundedHijackMouseTooltip)
101 } else {
102 this.progress.off(spriteEvents, this.boundedHijackMouseTooltip)
103
104 this.resetMouseTooltip()
105 }
106 }
107
108 private hijackMouseTooltip (evt: Event) {
109 const sprite = this.sprites[this.url]
110 const imgWidth = sprite.naturalWidth
111 const imgHeight = sprite.naturalHeight
112 const seekBarEl = this.seekBar.el()
113
114 if (!sprite.complete || !imgWidth || !imgHeight) {
115 this.resetMouseTooltip()
116 return
117 }
118
119 this.player.requestNamedAnimationFrame('StoryBoardPlugin#hijackMouseTooltip', () => {
120 const seekBarRect = videojs.dom.getBoundingClientRect(seekBarEl)
121 const playerRect = videojs.dom.getBoundingClientRect(this.player.el())
122
123 if (!seekBarRect || !playerRect) return
124
125 const seekBarX = videojs.dom.getPointerPosition(seekBarEl, evt).x
126 let position = seekBarX * this.player.duration()
127
128 const maxPosition = Math.round((imgHeight / this.height) * (imgWidth / this.width)) - 1
129 position = Math.min(position / this.interval, maxPosition)
130
131 const responsive = 600
132 const playerWidth = this.player.currentWidth()
133 const scaleFactor = responsive && playerWidth < responsive
134 ? playerWidth / responsive
135 : 1
136 const columns = imgWidth / this.width
137
138 const scaledWidth = this.width * scaleFactor
139 const scaledHeight = this.height * scaleFactor
140 const cleft = Math.floor(position % columns) * -scaledWidth
141 const ctop = Math.floor(position / columns) * -scaledHeight
142
143 const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
144 const topOffset = -scaledHeight - 60
145
146 const previewHalfSize = Math.round(scaledWidth / 2)
147 let left = seekBarRect.width * seekBarX - previewHalfSize
148
149 // Seek bar doesn't take all the player width, so we can add/minus a few more pixels
150 const minLeft = playerRect.left - seekBarRect.left
151 const maxLeft = seekBarRect.width - scaledWidth + (playerRect.right - seekBarRect.right)
152
153 if (left < minLeft) left = minLeft
154 if (left > maxLeft) left = maxLeft
155
156 const tooltipStyle: { [id: string]: string } = {
157 'background-image': `url("${this.url}")`,
158 'background-repeat': 'no-repeat',
159 'background-position': `${cleft}px ${ctop}px`,
160 'background-size': bgSize,
161
162 'color': '#fff',
163 'text-shadow': '1px 1px #000',
164
165 'position': 'relative',
166
167 'top': `${topOffset}px`,
168
169 'border': '1px solid #000',
170
171 // border should not overlay thumbnail area
172 'width': `${scaledWidth + 2}px`,
173 'height': `${scaledHeight + 2}px`
174 }
175
176 tooltipStyle.left = `${left}px`
177
178 for (const [ key, value ] of Object.entries(tooltipStyle)) {
179 this.spritePlaceholder.style.setProperty(key, value)
180 }
181 })
182 }
183
184 private resetMouseTooltip () {
185 if (this.spritePlaceholder) {
186 this.spritePlaceholder.style.cssText = ''
187 }
188 }
189
190 private isReady () {
191 return this.mouseTimeTooltip && this.width && this.height && this.url
192 }
193}
194
195videojs.registerPlugin('storyboard', StoryboardPlugin)
196
197export { StoryboardPlugin }
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 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' 2import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage'
3import { TheaterButtonOptions } from '../../types'
3 4
4const Button = videojs.getComponent('Button') 5const Button = videojs.getComponent('Button')
5class TheaterButton extends Button { 6class 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
53videojs.registerComponent('TheaterButton', TheaterButton) 66videojs.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
11class PeerTubeDockComponent extends Component { 11class 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
12class PeerTubeDockPlugin extends Plugin { 12class 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 @@
1import {
2 CommonOptions,
3 NextPreviousVideoButtonOptions,
4 PeerTubeLinkButtonOptions,
5 PeertubePlayerManagerOptions,
6 PlayerMode
7} from '../../types'
8
9export 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 @@
1export * 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 @@
1import videojs from 'video.js'
2import { copyToClipboard } from '@root-helpers/utils'
3import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
4import { isIOS, isSafari } from '@root-helpers/web-browser'
5import { buildVideoLink, decorateVideoLink, pick } from '@shared/core-utils'
6import { isDefaultLocale } from '@shared/core-utils/i18n'
7import { VideoJSPluginOptions } from '../../types'
8import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options'
9import { ControlBarOptionsBuilder } from './control-bar-options-builder'
10import { HLSOptionsBuilder } from './hls-options-builder'
11import { WebTorrentOptionsBuilder } from './webtorrent-options-builder'
12
13export 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 b5bdcd4e6..000000000
--- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
+++ /dev/null
@@ -1,47 +0,0 @@
1import { addQueryParams } from '../../../../../../shared/core-utils'
2import { PeertubePlayerManagerOptions, WebtorrentPluginOptions } from '../../types'
3
4export 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 requiresAuth: commonOptions.requiresAuth,
30
31 buildWebSeedUrls: file => {
32 if (!commonOptions.requiresAuth) 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 @@
1import debug from 'debug'
1import videojs from 'video.js' 2import videojs from 'video.js'
2import { PlaybackMetricCreate } from '../../../../../../shared/models'
3import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types'
4import { logger } from '@root-helpers/logger' 3import { logger } from '@root-helpers/logger'
4import { PlaybackMetricCreate } from '../../../../../../shared/models'
5import { MetricsPluginOptions, PlayerNetworkInfo } from '../../types'
6
7const debugLogger = debug('peertube:player:metrics')
5 8
6const Plugin = videojs.getPlugin('plugin') 9const Plugin = videojs.getPlugin('plugin')
7 10
8class MetricsPlugin extends Plugin { 11class 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
3const Component = videojs.getComponent('Component') 3const Component = videojs.getComponent('Component')
4class PeerTubeMobileButtons extends Component { 4class 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
17type HookFn = (player: videojs.Player, hljs: Hlsjs) => void 21type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
18 22
19const registerSourceHandler = function (vjs: typeof videojs) { 23const 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
59function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { 66// ---------------------------------------------------------------------------
60 const player = this 67// HLS options plugin
68// ---------------------------------------------------------------------------
61 69
62 if (!options) return 70const Plugin = videojs.getPlugin('plugin')
63 71
64 if (!player.srOptions_) { 72class 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
77const registerConfigPlugin = function (vjs: typeof videojs) { 107videojs.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
83class Html5Hlsjs { 113export 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
437export {
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'
3import { Events, Segment } from '@peertube/p2p-media-loader-core' 3import { Events, Segment } from '@peertube/p2p-media-loader-core'
4import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' 4import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
5import { logger } from '@root-helpers/logger' 5import { logger } from '@root-helpers/logger'
6import { addQueryParams, timeToInt } from '@shared/core-utils' 6import { addQueryParams } from '@shared/core-utils'
7import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' 7import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
8import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' 8import { SettingsButton } from '../settings/settings-menu-button'
9
10registerConfigPlugin(videojs)
11registerSourceHandler(videojs)
12 9
13const Plugin = videojs.getPlugin('plugin') 10const Plugin = videojs.getPlugin('plugin')
14class P2pMediaLoaderPlugin extends Plugin { 11class 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 44a31bfb4..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,21 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string
9 9
10const maxRetries = 10 10const maxRetries = 10
11 11
12function segmentValidatorFactory (options: { 12export class SegmentValidator {
13 serverUrl: string 13
14 segmentsSha256Url: string 14 private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/
15 authorizationHeader: () => string 15
16 requiresAuth: boolean 16 private destroyed = false
17}) { 17
18 const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options 18 constructor (private readonly options: {
19 19 serverUrl: string
20 let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) 20 segmentsSha256Url: string
21 const regex = /bytes=(\d+)-(\d+)/ 21 authorizationHeader: () => string
22 22 requiresUserAuth: boolean
23 return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { 23 requiresPassword: boolean
24 videoPassword: () => string
25 }) {
26
27 }
28
29 async validate (segment: Segment, _method: string, _peerId: string, retry = 1) {
30 if (this.destroyed) return
31
24 const filename = basename(removeQueryParams(segment.url)) 32 const filename = basename(removeQueryParams(segment.url))
25 33
26 const segmentValue = (await segmentsJSON)[filename] 34 const segmentValue = (await this.fetchSha256Segments())[filename]
27 35
28 if (!segmentValue && retry > maxRetries) { 36 if (!segmentValue && retry > maxRetries) {
29 throw new Error(`Unknown segment name ${filename} in segment validator`) 37 throw new Error(`Unknown segment name ${filename} in segment validator`)
@@ -34,8 +42,7 @@ function segmentValidatorFactory (options: {
34 42
35 await wait(500) 43 await wait(500)
36 44
37 segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) 45 await this.validate(segment, _method, _peerId, retry + 1)
38 await segmentValidator(segment, _method, _peerId, retry + 1)
39 46
40 return 47 return
41 } 48 }
@@ -46,7 +53,7 @@ function segmentValidatorFactory (options: {
46 if (typeof segmentValue === 'string') { 53 if (typeof segmentValue === 'string') {
47 hashShouldBe = segmentValue 54 hashShouldBe = segmentValue
48 } else { 55 } else {
49 const captured = regex.exec(segment.range) 56 const captured = this.bytesRangeRegex.exec(segment.range)
50 range = captured[1] + '-' + captured[2] 57 range = captured[1] + '-' + captured[2]
51 58
52 hashShouldBe = segmentValue[range] 59 hashShouldBe = segmentValue[range]
@@ -56,7 +63,7 @@ function segmentValidatorFactory (options: {
56 throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) 63 throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
57 } 64 }
58 65
59 const calculatedSha = await sha256Hex(segment.data) 66 const calculatedSha = await this.sha256Hex(segment.data)
60 if (calculatedSha !== hashShouldBe) { 67 if (calculatedSha !== hashShouldBe) {
61 throw new Error( 68 throw new Error(
62 `Hashes does not correspond for segment ${filename}/${range}` + 69 `Hashes does not correspond for segment ${filename}/${range}` +
@@ -64,61 +71,53 @@ function segmentValidatorFactory (options: {
64 ) 71 )
65 } 72 }
66 } 73 }
67}
68 74
69// --------------------------------------------------------------------------- 75 destroy () {
76 this.destroyed = true
77 }
70 78
71export { 79 private fetchSha256Segments (): Promise<SegmentsJSON> {
72 segmentValidatorFactory 80 let headers: { [ id: string ]: string } = {}
73}
74 81
75// --------------------------------------------------------------------------- 82 if (isSameOrigin(this.options.serverUrl, this.options.segmentsSha256Url)) {
76 83 if (this.options.requiresPassword) headers = { 'x-peertube-video-password': this.options.videoPassword() }
77function fetchSha256Segments (options: { 84 else if (this.options.requiresUserAuth) headers = { Authorization: this.options.authorizationHeader() }
78 serverUrl: string 85 }
79 segmentsSha256Url: string
80 authorizationHeader: () => string
81 requiresAuth: boolean
82}): Promise<SegmentsJSON> {
83 const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options
84
85 const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url)
86 ? { Authorization: authorizationHeader() }
87 : {}
88
89 return fetch(segmentsSha256Url, { headers })
90 .then(res => res.json() as Promise<SegmentsJSON>)
91 .catch(err => {
92 logger.error('Cannot get sha256 segments', err)
93 return {}
94 })
95}
96
97async function sha256Hex (data?: ArrayBuffer) {
98 if (!data) return undefined
99 86
100 if (window.crypto.subtle) { 87 return fetch(this.options.segmentsSha256Url, { headers })
101 return window.crypto.subtle.digest('SHA-256', data) 88 .then(res => res.json() as Promise<SegmentsJSON>)
102 .then(data => bufferToHex(data)) 89 .catch(err => {
90 logger.error('Cannot get sha256 segments', err)
91 return {}
92 })
103 } 93 }
104 94
105 // Fallback for non HTTPS context 95 private async sha256Hex (data?: ArrayBuffer) {
106 const shaModule = (await import('sha.js') as any).default 96 if (!data) return undefined
107 // eslint-disable-next-line new-cap 97
108 return new shaModule.sha256().update(Buffer.from(data)).digest('hex') 98 if (window.crypto.subtle) {
109} 99 return window.crypto.subtle.digest('SHA-256', data)
100 .then(data => this.bufferToHex(data))
101 }
110 102
111// Thanks: https://stackoverflow.com/a/53307879 103 // Fallback for non HTTPS context
112function bufferToHex (buffer?: ArrayBuffer) { 104 const shaModule = (await import('sha.js') as any).default
113 if (!buffer) return '' 105 // eslint-disable-next-line new-cap
106 return new shaModule.sha256().update(Buffer.from(data)).digest('hex')
107 }
114 108
115 let s = '' 109 // Thanks: https://stackoverflow.com/a/53307879
116 const h = '0123456789abcdef' 110 private bufferToHex (buffer?: ArrayBuffer) {
117 const o = new Uint8Array(buffer) 111 if (!buffer) return ''
118 112
119 o.forEach((v: any) => { 113 let s = ''
120 s += h[v >> 4] + h[v & 15] 114 const h = '0123456789abcdef'
121 }) 115 const o = new Uint8Array(buffer)
122 116
123 return s 117 o.forEach((v: any) => {
118 s += h[v >> 4] + h[v & 15]
119 })
120
121 return s
122 }
124} 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 @@
1import debug from 'debug' 1import debug from 'debug'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { logger } from '@root-helpers/logger' 3import { logger } from '@root-helpers/logger'
4import { isMobile } from '@root-helpers/web-browser' 4import { isIOS, isMobile } from '@root-helpers/web-browser'
5import { timeToInt } from '@shared/core-utils' 5import { timeToInt } from '@shared/core-utils'
6import { VideoView, VideoViewEvent } from '@shared/models/videos' 6import { VideoView, VideoViewEvent } from '@shared/models/videos'
7import { 7import {
@@ -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'
16import { PeerTubePluginOptions, VideoJSCaption } from '../../types' 16import { PeerTubePluginOptions } from '../../types'
17import { SettingsButton } from '../settings/settings-menu-button' 17import { SettingsButton } from '../settings/settings-menu-button'
18 18
19const debugLogger = debug('peertube:player:peertube') 19const debugLogger = debug('peertube:player:peertube')
@@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube')
21const Plugin = videojs.getPlugin('plugin') 21const Plugin = videojs.getPlugin('plugin')
22 22
23class PeerTubePlugin extends Plugin { 23class 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
319videojs.registerPlugin('peertube', PeerTubePlugin) 455videojs.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 @@
1import {
2 NextPreviousVideoButtonOptions,
3 PeerTubeLinkButtonOptions,
4 PeerTubePlayerContructorOptions,
5 PeerTubePlayerLoadOptions,
6 TheaterButtonOptions
7} from '../../types'
8
9type 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
19export 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 194991fa4..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'
3import { logger } from '@root-helpers/logger' 3import { logger } from '@root-helpers/logger'
4import { LiveVideoLatencyMode } from '@shared/models' 4import { LiveVideoLatencyMode } from '@shared/models'
5import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' 5import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
6import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' 6import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types'
7import { PeertubePlayerManagerOptions } from '../../types/manager-options'
8import { getRtcConfig, isSameOrigin } from '../common' 7import { getRtcConfig, isSameOrigin } from '../common'
9import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' 8import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
10import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' 9import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
11import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' 10import { SegmentValidator } from '../p2p-media-loader/segment-validator'
11
12type ConstructorOptions =
13 Pick<PeerTubePlayerContructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader'> &
14 Pick<PeerTubePlayerLoadOptions, 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' |
15 'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'>
12 16
13export class HLSOptionsBuilder { 17export 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 requiresAuth: commonOptions.requiresAuth, 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,28 +101,28 @@ export class HLSOptionsBuilder {
88 httpFailedSegmentTimeout: 1000, 101 httpFailedSegmentTimeout: 1000,
89 102
90 xhrSetup: (xhr, url) => { 103 xhrSetup: (xhr, url) => {
91 if (!this.options.common.requiresAuth) return 104 const { requiresUserAuth, requiresPassword } = this.options
92 if (!isSameOrigin(this.options.common.serverUrl, url)) return 105
106 if (!(requiresUserAuth || requiresPassword)) return
107
108 if (!isSameOrigin(this.options.serverUrl, url)) return
109
110 if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword())
93 111
94 xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) 112 else xhr.setRequestHeader('Authorization', this.options.authorizationHeader())
95 }, 113 },
96 114
97 segmentValidator: segmentValidatorFactory({ 115 segmentValidator: segmentValidator.validate.bind(segmentValidator),
98 segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
99 authorizationHeader: this.options.common.authorizationHeader,
100 requiresAuth: this.options.common.requiresAuth,
101 serverUrl: this.options.common.serverUrl
102 }),
103 116
104 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), 117 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
105 118
106 useP2P: this.options.common.p2pEnabled, 119 useP2P: this.options.p2pEnabled,
107 consumeOnly, 120 consumeOnly,
108 121
109 ...specificLiveOrVODOptions 122 ...specificLiveOrVODOptions
110 }, 123 },
111 segments: { 124 segments: {
112 swarmId: this.options.p2pMediaLoader.playlistUrl, 125 swarmId: this.options.hls.playlistUrl,
113 forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 126 forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20
114 } 127 }
115 } 128 }
@@ -120,7 +133,7 @@ export class HLSOptionsBuilder {
120 requiredSegmentsPriority: 1 133 requiredSegmentsPriority: 1
121 } 134 }
122 135
123 const latencyMode = this.options.common.liveOptions.latencyMode 136 const latencyMode = this.options.liveOptions.latencyMode
124 137
125 switch (latencyMode) { 138 switch (latencyMode) {
126 case LiveVideoLatencyMode.SMALL_LATENCY: 139 case LiveVideoLatencyMode.SMALL_LATENCY:
@@ -158,7 +171,7 @@ export class HLSOptionsBuilder {
158 // --------------------------------------------------------------------------- 171 // ---------------------------------------------------------------------------
159 172
160 private getHLSJSOptions (loader: P2PMediaLoader) { 173 private getHLSJSOptions (loader: P2PMediaLoader) {
161 const specificLiveOrVODOptions = this.options.common.isLive 174 const specificLiveOrVODOptions = this.options.isLive
162 ? this.getHLSLiveOptions() 175 ? this.getHLSLiveOptions()
163 : this.getHLSVODOptions() 176 : this.getHLSVODOptions()
164 177
@@ -186,7 +199,7 @@ export class HLSOptionsBuilder {
186 } 199 }
187 200
188 private getHLSLiveOptions () { 201 private getHLSLiveOptions () {
189 const latencyMode = this.options.common.liveOptions.latencyMode 202 const latencyMode = this.options.liveOptions.latencyMode
190 203
191 switch (latencyMode) { 204 switch (latencyMode) {
192 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 @@
1export * from './control-bar-options-builder'
2export * from './hls-options-builder'
3export * 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 @@
1import { PeerTubePlayerLoadOptions, WebVideoPluginOptions } from '../../types'
2
3type ConstructorOptions = Pick<PeerTubePlayerLoadOptions, 'videoFileToken' | 'webVideo' | 'hls' | 'startTime'>
4
5export 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
59videojs.registerComponent('PlaylistButton', PlaylistButton) 61videojs.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')
8class PlaylistMenuItem extends Component { 8class 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'
6const Component = videojs.getComponent('Component') 6const Component = videojs.getComponent('Component')
7 7
8class PlaylistMenu extends Component { 8class 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')
8class PlaylistPlugin extends Plugin { 8class 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
33Component.registerComponent('SettingsDialog', SettingsDialog) 45Component.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'
7interface StatsCardOptions extends videojs.ComponentOptions { 7interface 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 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { UpNextPluginOptions } from '../../types'
2 3
3function getMainTemplate (options: any) { 4function 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
26export interface EndCardOptions extends videojs.ComponentOptions { 27export 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
37const Component = videojs.getComponent('Component') 33const 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 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { UpNextPluginOptions } from '../../types'
2import { EndCardOptions } from './end-card' 3import { EndCardOptions } from './end-card'
3 4
4const Plugin = videojs.getPlugin('plugin') 5const Plugin = videojs.getPlugin('plugin')
5 6
6class UpNextPlugin extends Plugin { 7class 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 @@
1import debug from 'debug'
2import videojs from 'video.js'
3import { logger } from '@root-helpers/logger'
4import { addQueryParams } from '@shared/core-utils'
5import { VideoFile } from '@shared/models'
6import { PeerTubeResolution, PlayerNetworkInfo, WebVideoPluginOptions } from '../../types'
7
8const debugLogger = debug('peertube:player:web-video-plugin')
9
10const Plugin = videojs.getPlugin('plugin')
11
12class 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
185videojs.registerPlugin('webVideo', WebVideoPlugin)
186export { 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
5import Dexie from 'dexie'
6import { EventEmitter } from 'events'
7import { logger } from '@root-helpers/logger'
8
9class 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
21class 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
33export 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
3const MediaElementWrapper = require('mediasource')
4import { logger } from '@root-helpers/logger'
5import { extname } from 'path'
6const Videostream = require('videostream')
7
8const VIDEOSTREAM_EXTS = [
9 '.m4a',
10 '.m4v',
11 '.mp4'
12]
13
14type RenderMediaOptions = {
15 controls: boolean
16 autoplay: boolean
17}
18
19function 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
30function 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
105function 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
117function 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
132export {
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 3dde44a60..000000000
--- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
+++ /dev/null
@@ -1,663 +0,0 @@
1import videojs from 'video.js'
2import * as WebTorrent from 'webtorrent'
3import { logger } from '@root-helpers/logger'
4import { isIOS } from '@root-helpers/web-browser'
5import { addQueryParams, timeToInt } from '@shared/core-utils'
6import { VideoFile } from '@shared/models'
7import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage'
8import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types'
9import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common'
10import { PeertubeChunkStore } from './peertube-chunk-store'
11import { renderVideo } from './video-renderer'
12
13const CacheChunkStore = require('cache-chunk-store')
14
15type PlayOptions = {
16 forcePlay?: boolean
17 seek?: number
18 delay?: number
19}
20
21const Plugin = videojs.getPlugin('plugin')
22
23class 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 requiresAuth: 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.requiresAuth = options.requiresAuth
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.requiresAuth && 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
662videojs.registerPlugin('webtorrent', WebTorrentPlugin)
663export { 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 @@
1export * from './manager-options' 1export * from './peertube-player-options'
2export * from './peertube-videojs-typings' 2export * from './peertube-videojs-typings'
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
deleted file mode 100644
index c14fd7e99..000000000
--- a/client/src/assets/player/types/manager-options.ts
+++ /dev/null
@@ -1,98 +0,0 @@
1import { PluginsManager } from '@root-helpers/plugins-manager'
2import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
3import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings'
4
5export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
6
7export type WebtorrentOptions = {
8 videoFiles: VideoFile[]
9}
10
11export type P2PMediaLoaderOptions = {
12 playlistUrl: string
13 segmentsSha256Url: string
14 trackerAnnounce: string[]
15 redundancyBaseUrls: string[]
16 videoFiles: VideoFile[]
17}
18
19export interface CustomizationOptions {
20 startTime: number | string
21 stopTime: number | string
22
23 controls?: boolean
24 controlBar?: boolean
25
26 muted?: boolean
27 loop?: boolean
28 subtitle?: string
29 resume?: string
30
31 peertubeLink: boolean
32
33 playbackRate?: number | string
34}
35
36export interface CommonOptions extends CustomizationOptions {
37 playerElement: HTMLVideoElement
38 onPlayerElementChange: (element: HTMLVideoElement) => void
39
40 autoplay: boolean
41 forceAutoplay: boolean
42
43 p2pEnabled: boolean
44
45 nextVideo?: () => void
46 hasNextVideo?: () => boolean
47
48 previousVideo?: () => void
49 hasPreviousVideo?: () => boolean
50
51 playlist?: PlaylistPluginOptions
52
53 videoDuration: number
54 enableHotkeys: boolean
55 inactivityTimeout: number
56 poster: string
57
58 videoViewIntervalMs: number
59
60 instanceName: string
61
62 theaterButton: boolean
63 captions: boolean
64
65 videoViewUrl: string
66 authorizationHeader?: () => string
67
68 metricsUrl: string
69
70 embedUrl: string
71 embedTitle: string
72
73 isLive: boolean
74 liveOptions?: {
75 latencyMode: LiveVideoLatencyMode
76 }
77
78 language?: string
79
80 videoCaptions: VideoJSCaption[]
81
82 videoUUID: string
83 videoShortUUID: string
84
85 serverUrl: string
86 requiresAuth: boolean
87 videoFileToken: () => string
88
89 errorNotifier: (message: string) => void
90}
91
92export type PeertubePlayerManagerOptions = {
93 common: CommonOptions
94 webtorrent: WebtorrentOptions
95 p2pMediaLoader?: P2PMediaLoaderOptions
96
97 pluginsManager: PluginsManager
98}
diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts
new file mode 100644
index 000000000..e1b8c7fab
--- /dev/null
+++ b/client/src/assets/player/types/peertube-player-options.ts
@@ -0,0 +1,117 @@
1import { PluginsManager } from '@root-helpers/plugins-manager'
2import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
3import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
4import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
5
6export type PlayerMode = 'web-video' | 'p2p-media-loader'
7
8export type PeerTubePlayerContructorOptions = {
9 playerElement: () => HTMLVideoElement
10
11 controls: boolean
12 controlBar: boolean
13
14 muted: boolean
15 loop: boolean
16
17 peertubeLink: () => boolean
18
19 playbackRate?: number | string
20
21 enableHotkeys: boolean
22 inactivityTimeout: number
23
24 videoViewIntervalMs: number
25
26 instanceName: string
27
28 theaterButton: boolean
29
30 authorizationHeader: () => string
31
32 metricsUrl: string
33 serverUrl: string
34
35 errorNotifier: (message: string) => void
36
37 // Current web browser language
38 language: string
39
40 pluginsManager: PluginsManager
41}
42
43export type PeerTubePlayerLoadOptions = {
44 mode: PlayerMode
45
46 startTime?: number | string
47 stopTime?: number | string
48
49 autoplay: boolean
50 forceAutoplay: boolean
51
52 poster: string
53 subtitle?: string
54 videoViewUrl: string
55
56 embedUrl: string
57 embedTitle: string
58
59 isLive: boolean
60
61 liveOptions?: {
62 latencyMode: LiveVideoLatencyMode
63 }
64
65 videoCaptions: VideoJSCaption[]
66 storyboard: VideoJSStoryboard
67
68 videoUUID: string
69 videoShortUUID: string
70
71 duration: number
72
73 requiresUserAuth: boolean
74 videoFileToken: () => string
75 requiresPassword: boolean
76 videoPassword: () => string
77
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
105}
106
107export type WebVideoOptions = {
108 videoFiles: VideoFile[]
109}
110
111export type HLSOptions = {
112 playlistUrl: string
113 segmentsSha256Url: string
114 trackerAnnounce: string[]
115 redundancyBaseUrls: string[]
116 videoFiles: VideoFile[]
117}
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index eadf56cfa..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'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' 4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 5import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
6import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin' 6import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
7import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
8import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin'
9import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin'
7import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' 10import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
8import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' 11import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
9import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' 12import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -12,9 +15,10 @@ import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
12import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' 15import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
13import { StatsCardOptions } from '../shared/stats/stats-card' 16import { StatsCardOptions } from '../shared/stats/stats-card'
14import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' 17import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
15import { EndCardOptions } from '../shared/upnext/end-card' 18import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
16import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' 19import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
17import { PlayerMode } from './manager-options' 20import { PlayerMode } from './peertube-player-options'
21import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
18 22
19declare module 'video.js' { 23declare module 'video.js' {
20 24
@@ -31,33 +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 textTracks (): TextTrackList & { 61 storyboard (options?: StoryboardOptions): StoryboardPlugin
53 tracks_: (TextTrack & { id: string, label: string, src: string })[]
54 }
55 62
56 peertubeDock (options: PeerTubeDockPluginOptions): void 63 peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
57 64
58 upnext (options: Partial<EndCardOptions>): void 65 upnext (options?: UpNextPluginOptions): UpNextPlugin
59 66
60 playlist (): PlaylistPlugin 67 playlist (options?: PlaylistPluginOptions): PlaylistPlugin
61 } 68 }
62} 69}
63 70
@@ -89,33 +96,43 @@ type VideoJSCaption = {
89 src: string 96 src: string
90} 97}
91 98
99type VideoJSStoryboard = {
100 url: string
101 width: number
102 height: number
103 interval: number
104}
105
92type PeerTubePluginOptions = { 106type PeerTubePluginOptions = {
93 mode: PlayerMode 107 hasAutoplay: () => videojs.Autoplay
94 108
95 autoplay: videojs.Autoplay 109 videoViewUrl: () => string
96 videoDuration: number 110 videoViewIntervalMs: number
97 111
98 videoViewUrl: string
99 authorizationHeader?: () => string 112 authorizationHeader?: () => string
100 113
101 subtitle?: string 114 videoDuration: () => number
102 115
103 videoCaptions: VideoJSCaption[] 116 startTime: () => number | string
104 117 stopTime: () => number | string
105 startTime: number | string
106 stopTime: number | string
107 118
108 isLive: boolean 119 videoCaptions: () => VideoJSCaption[]
109 120 isLive: () => boolean
110 videoUUID: string 121 videoUUID: () => string
111 122 subtitle: () => string
112 videoViewIntervalMs: number
113} 123}
114 124
115type MetricsPluginOptions = { 125type MetricsPluginOptions = {
116 mode: PlayerMode 126 mode: () => PlayerMode
117 metricsUrl: string 127 metricsUrl: () => string
118 videoUUID: string 128 videoUUID: () => string
129}
130
131type StoryboardOptions = {
132 url: string
133 width: number
134 height: number
135 interval: number
119} 136}
120 137
121type PlaylistPluginOptions = { 138type PlaylistPluginOptions = {
@@ -128,37 +145,36 @@ type PlaylistPluginOptions = {
128 onItemClicked: (element: VideoPlaylistElement) => void 145 onItemClicked: (element: VideoPlaylistElement) => void
129} 146}
130 147
148type UpNextPluginOptions = {
149 timeout: number
150
151 next: () => void
152 getTitle: () => string
153 isDisplayed: () => boolean
154 isSuspended: () => boolean
155}
156
131type NextPreviousVideoButtonOptions = { 157type NextPreviousVideoButtonOptions = {
132 type: 'next' | 'previous' 158 type: 'next' | 'previous'
133 handler: () => void 159 handler?: () => void
160 isDisplayed: () => boolean
134 isDisabled: () => boolean 161 isDisabled: () => boolean
135} 162}
136 163
137type PeerTubeLinkButtonOptions = { 164type PeerTubeLinkButtonOptions = {
138 shortUUID: string 165 isDisplayed: () => boolean
166 shortUUID: () => string
139 instanceName: string 167 instanceName: string
140} 168}
141 169
142type PeerTubeP2PInfoButtonOptions = { 170type TheaterButtonOptions = {
143 p2pEnabled: boolean 171 isDisplayed: () => boolean
144} 172}
145 173
146type WebtorrentPluginOptions = { 174type WebVideoPluginOptions = {
147 playerElement: HTMLVideoElement
148
149 autoplay: videojs.Autoplay
150 videoDuration: number
151
152 videoFiles: VideoFile[] 175 videoFiles: VideoFile[]
153
154 startTime: number | string 176 startTime: number | string
155
156 playerRefusedP2P: boolean
157
158 requiresAuth: boolean
159 videoFileToken: () => string 177 videoFileToken: () => string
160
161 buildWebSeedUrls: (file: VideoFile) => string[]
162} 178}
163 179
164type P2PMediaLoaderPluginOptions = { 180type P2PMediaLoaderPluginOptions = {
@@ -166,16 +182,17 @@ type P2PMediaLoaderPluginOptions = {
166 type: string 182 type: string
167 src: string 183 src: string
168 184
169 startTime: number | string
170
171 loader: P2PMediaLoader 185 loader: P2PMediaLoader
186 segmentValidator: SegmentValidator
172 187
173 requiresAuth: boolean 188 requiresUserAuth: boolean
174 videoFileToken: () => string 189 videoFileToken: () => string
175} 190}
176 191
177export type P2PMediaLoader = { 192export type P2PMediaLoader = {
178 getEngine(): Engine 193 getEngine(): Engine
194
195 destroy: () => void
179} 196}
180 197
181type VideoJSPluginOptions = { 198type VideoJSPluginOptions = {
@@ -184,7 +201,7 @@ type VideoJSPluginOptions = {
184 peertube: PeerTubePluginOptions 201 peertube: PeerTubePluginOptions
185 metrics: MetricsPluginOptions 202 metrics: MetricsPluginOptions
186 203
187 webtorrent?: WebtorrentPluginOptions 204 webVideo?: WebVideoPluginOptions
188 205
189 p2pMediaLoader?: P2PMediaLoaderPluginOptions 206 p2pMediaLoader?: P2PMediaLoaderPluginOptions
190} 207}
@@ -211,14 +228,14 @@ type AutoResolutionUpdateData = {
211} 228}
212 229
213type PlayerNetworkInfo = { 230type PlayerNetworkInfo = {
214 source: 'webtorrent' | 'p2p-media-loader' 231 source: 'web-video' | 'p2p-media-loader'
215 232
216 http: { 233 http: {
217 downloadSpeed: number 234 downloadSpeed?: number
218 downloaded: number 235 downloaded: number
219 } 236 }
220 237
221 p2p: { 238 p2p?: {
222 downloadSpeed: number 239 downloadSpeed: number
223 uploadSpeed: number 240 uploadSpeed: number
224 downloaded: number 241 downloaded: number
@@ -227,7 +244,7 @@ type PlayerNetworkInfo = {
227 } 244 }
228 245
229 // In bytes 246 // In bytes
230 bandwidthEstimate: number 247 bandwidthEstimate?: number
231} 248}
232 249
233type PlaylistItemOptions = { 250type PlaylistItemOptions = {
@@ -238,6 +255,8 @@ type PlaylistItemOptions = {
238 255
239export { 256export {
240 PlayerNetworkInfo, 257 PlayerNetworkInfo,
258 TheaterButtonOptions,
259 VideoJSStoryboard,
241 PlaylistItemOptions, 260 PlaylistItemOptions,
242 NextPreviousVideoButtonOptions, 261 NextPreviousVideoButtonOptions,
243 ResolutionUpdateData, 262 ResolutionUpdateData,
@@ -246,11 +265,12 @@ export {
246 MetricsPluginOptions, 265 MetricsPluginOptions,
247 VideoJSCaption, 266 VideoJSCaption,
248 PeerTubePluginOptions, 267 PeerTubePluginOptions,
249 WebtorrentPluginOptions, 268 WebVideoPluginOptions,
250 P2PMediaLoaderPluginOptions, 269 P2PMediaLoaderPluginOptions,
251 PeerTubeResolution, 270 PeerTubeResolution,
252 VideoJSPluginOptions, 271 VideoJSPluginOptions,
272 UpNextPluginOptions,
253 LoadedQualityData, 273 LoadedQualityData,
254 PeerTubeLinkButtonOptions, 274 StoryboardOptions,
255 PeerTubeP2PInfoButtonOptions 275 PeerTubeLinkButtonOptions
256} 276}
diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts
index 9022b908b..4a44615fb 100644
--- a/client/src/root-helpers/video.ts
+++ b/client/src/root-helpers/video.ts
@@ -41,14 +41,21 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b
41 return userP2PEnabled 41 return userP2PEnabled
42} 42}
43 43
44function videoRequiresAuth (video: Video) { 44function videoRequiresUserAuth (video: Video, videoPassword?: string) {
45 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) 45 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
46 (video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
47
48}
49
50function videoRequiresFileToken (video: Video, videoPassword?: string) {
51 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
46} 52}
47 53
48export { 54export {
49 buildVideoOrPlaylistEmbed, 55 buildVideoOrPlaylistEmbed,
50 isP2PEnabled, 56 isP2PEnabled,
51 videoRequiresAuth 57 videoRequiresUserAuth,
58 videoRequiresFileToken
52} 59}
53 60
54// --------------------------------------------------------------------------- 61// ---------------------------------------------------------------------------
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 96b3adf66..09a75e2fd 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -12,11 +12,8 @@
12 text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); 12 text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
13 transition: visibility 0.3s, opacity 0.3s !important; 13 transition: visibility 0.3s, opacity 0.3s !important;
14 14
15 &.control-bar-hidden { 15 > button:not(.vjs-hidden):first-child,
16 display: none !important; 16 > button.vjs-hidden + button:not(.vjs-hidden) {
17 }
18
19 > button:first-child {
20 @include margin-left($first-control-bar-element-margin-left); 17 @include margin-left($first-control-bar-element-margin-left);
21 } 18 }
22 19
@@ -79,6 +76,7 @@
79 top: -0.3em; 76 top: -0.3em;
80 } 77 }
81 78
79 // Only used on mobile
82 .vjs-time-tooltip { 80 .vjs-time-tooltip {
83 display: none; 81 display: none;
84 } 82 }
@@ -152,7 +150,7 @@
152 } 150 }
153 } 151 }
154 152
155 .vjs-live-control { 153 .vjs-pt-live-control {
156 padding: 5px 7px; 154 padding: 5px 7px;
157 border-radius: 3px; 155 border-radius: 3px;
158 height: fit-content; 156 height: fit-content;
@@ -230,6 +228,7 @@
230 .vjs-next-video, 228 .vjs-next-video,
231 .vjs-previous-video { 229 .vjs-previous-video {
232 width: $control-bar-button-width - 4px; 230 width: $control-bar-button-width - 4px;
231 cursor: pointer;
233 232
234 &.vjs-disabled { 233 &.vjs-disabled {
235 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 84d7a00f1..b0019d2c9 100644
--- a/client/src/sass/player/mobile.scss
+++ b/client/src/sass/player/mobile.scss
@@ -6,6 +6,31 @@
6/* Special mobile style */ 6/* Special mobile style */
7 7
8.video-js.vjs-peertube-skin.vjs-is-mobile { 8.video-js.vjs-peertube-skin.vjs-is-mobile {
9 // No hover means we can't display the storyboard/time tooltip on mouse hover
10 // Use the time tooltip in progress control instead
11 .vjs-mouse-display {
12 display: none !important;
13 }
14
15 .vjs-storyboard-sprite-placeholder {
16 display: none;
17 }
18
19 .vjs-progress-control .vjs-sliding {
20
21 .vjs-time-tooltip,
22 .vjs-storyboard-sprite-placeholder {
23 display: block !important;
24
25 visibility: visible !important;
26 }
27
28 .vjs-time-tooltip {
29 color: #fff;
30 background-color: rgba(0, 0, 0, 0.8);
31 }
32 }
33
9 .vjs-control-bar { 34 .vjs-control-bar {
10 .vjs-progress-control .vjs-slider .vjs-play-progress { 35 .vjs-progress-control .vjs-slider .vjs-play-progress {
11 // Always display the circle on mobile 36 // Always display the circle on mobile
@@ -145,7 +170,8 @@
145 } 170 }
146 } 171 }
147 172
148 &.vjs-scrubbing { 173 &.vjs-scrubbing,
174 &.vjs-mobile-sliding {
149 .vjs-mobile-buttons-overlay { 175 .vjs-mobile-buttons-overlay {
150 display: none; 176 display: none;
151 } 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 @@
1module.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 @@
1module.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 @@
1module.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 @@
1import './embed.scss' 1import './embed.scss'
2import * as Channel from 'jschannel' 2import * as Channel from 'jschannel'
3import { logger } from '../../root-helpers' 3import { logger } from '../../root-helpers'
4import { PeerTubeResolution, PeerTubeTextTrack } from '../player/definitions' 4import { PeerTubeResolution, PeerTubeTextTrack } from '../embed-player-api/definitions'
5import { PeerTubeEmbed } from './embed' 5import { 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 32bf5f655..e2dc02b60 100644
--- a/client/src/standalone/videos/embed.html
+++ b/client/src/standalone/videos/embed.html
@@ -41,9 +41,24 @@
41 <div id="error-content"></div> 41 <div id="error-content"></div>
42 </div> 42 </div>
43 43
44 <div id="video-wrapper"></div> 44 <div id="video-password-block">
45 <!-- eslint-disable-next-line @angular-eslint/template/elements-content -->
46 <h1 id="video-password-title"></h1>
47
48 <div id="video-password-content"></div>
49
50 <form id="video-password-form">
51 <input type="password" id="video-password-input" name="video-password" autocomplete="user-password" required>
52 <button type="submit" id="video-password-submit"> </button>
53 </form>
45 54
46 <div id="placeholder-preview"></div> 55 <div id="video-password-error"></div>
56 <svg xmlns="http://www.w3.org/2000/svg" width="4rem" height="4rem" viewBox="0 0 24 24">
57 <g fill="none" stroke="#c4c4c4" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></g>
58 </svg>
59 </div>
60
61 <div id="video-wrapper"></div>
47 62
48 <script type="text/javascript"> 63 <script type="text/javascript">
49 // Can be called in embed.ts 64 // Can be called in embed.ts
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 3631ea7e6..d15887478 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -24,7 +24,7 @@ html,
24body { 24body {
25 height: 100%; 25 height: 100%;
26 margin: 0; 26 margin: 0;
27 background-color: #000; 27 background-color: #0f0f10;
28} 28}
29 29
30#video-wrapper { 30#video-wrapper {
@@ -42,8 +42,10 @@ body {
42 } 42 }
43} 43}
44 44
45#error-block { 45#error-block,
46#video-password-block {
46 display: none; 47 display: none;
48 user-select: none;
47 49
48 flex-direction: column; 50 flex-direction: column;
49 align-content: center; 51 align-content: center;
@@ -86,6 +88,43 @@ body {
86 text-align: center; 88 text-align: center;
87} 89}
88 90
91#video-password-content {
92 @include margin(1rem, 0, 2rem);
93}
94
95#video-password-input,
96#video-password-submit {
97 line-height: 23px;
98 padding: 1rem;
99 margin: 1rem 0.5rem;
100 border: 0;
101 font-weight: 600;
102 border-radius: 3px!important;
103 font-size: 18px;
104 display: inline-block;
105}
106
107#video-password-submit {
108 color: #fff;
109 background-color: #f2690d;
110 cursor: pointer;
111}
112
113#video-password-submit:hover {
114 background-color: #f47825;
115}
116#video-password-error {
117 margin-top: 10px;
118 margin-bottom: 10px;
119 height: 2rem;
120 font-weight: bolder;
121}
122
123#video-password-block svg {
124 margin-left: auto;
125 margin-right: auto;
126}
127
89@media screen and (max-width: 300px) { 128@media screen and (max-width: 300px) {
90 #error-block { 129 #error-block {
91 font-size: 36px; 130 font-size: 36px;
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index cc4274b99..78b812ffd 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -1,18 +1,26 @@
1import './embed.scss' 1import './embed.scss'
2import '../../assets/player/shared/dock/peertube-dock-component' 2import '../../assets/player/shared/dock/peertube-dock-component'
3import '../../assets/player/shared/dock/peertube-dock-plugin' 3import '../../assets/player/shared/dock/peertube-dock-plugin'
4import { PeerTubeServerError } from 'src/types'
4import videojs from 'video.js' 5import videojs from 'video.js'
5import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 6import {
6import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models' 7 HTMLServerConfig,
7import { PeertubePlayerManager } from '../../assets/player' 8 ResultList,
9 ServerErrorCode,
10 VideoDetails,
11 VideoPlaylist,
12 VideoPlaylistElement,
13 VideoState
14} from '../../../../shared/models'
15import { PeerTubePlayer } from '../../assets/player/peertube-player'
8import { TranslationsManager } from '../../assets/player/translations-manager' 16import { TranslationsManager } from '../../assets/player/translations-manager'
9import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' 17import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
10import { PeerTubeEmbedApi } from './embed-api' 18import { PeerTubeEmbedApi } from './embed-api'
11import { 19import {
12 AuthHTTP, 20 AuthHTTP,
13 LiveManager, 21 LiveManager,
14 PeerTubePlugin, 22 PeerTubePlugin,
15 PlayerManagerOptions, 23 PlayerOptionsBuilder,
16 PlaylistFetcher, 24 PlaylistFetcher,
17 PlaylistTracker, 25 PlaylistTracker,
18 Translations, 26 Translations,
@@ -27,18 +35,26 @@ export class PeerTubeEmbed {
27 config: HTMLServerConfig 35 config: HTMLServerConfig
28 36
29 private translationsPromise: Promise<{ [id: string]: string }> 37 private translationsPromise: Promise<{ [id: string]: string }>
30 private PeertubePlayerManagerModulePromise: Promise<any> 38 private PeerTubePlayerManagerModulePromise: Promise<any>
31 39
32 private readonly http: AuthHTTP 40 private readonly http: AuthHTTP
33 private readonly videoFetcher: VideoFetcher 41 private readonly videoFetcher: VideoFetcher
34 private readonly playlistFetcher: PlaylistFetcher 42 private readonly playlistFetcher: PlaylistFetcher
35 private readonly peertubePlugin: PeerTubePlugin 43 private readonly peertubePlugin: PeerTubePlugin
36 private readonly playerHTML: PlayerHTML 44 private readonly playerHTML: PlayerHTML
37 private readonly playerManagerOptions: PlayerManagerOptions 45 private readonly playerOptionsBuilder: PlayerOptionsBuilder
38 private readonly liveManager: LiveManager 46 private readonly liveManager: LiveManager
39 47
48 private peertubePlayer: PeerTubePlayer
49
40 private playlistTracker: PlaylistTracker 50 private playlistTracker: PlaylistTracker
41 51
52 private alreadyInitialized = false
53 private alreadyPlayed = false
54
55 private videoPassword: string
56 private requiresPassword: boolean
57
42 constructor (videoWrapperId: string) { 58 constructor (videoWrapperId: string) {
43 logger.registerServerSending(window.location.origin) 59 logger.registerServerSending(window.location.origin)
44 60
@@ -48,8 +64,9 @@ export class PeerTubeEmbed {
48 this.playlistFetcher = new PlaylistFetcher(this.http) 64 this.playlistFetcher = new PlaylistFetcher(this.http)
49 this.peertubePlugin = new PeerTubePlugin(this.http) 65 this.peertubePlugin = new PeerTubePlugin(this.http)
50 this.playerHTML = new PlayerHTML(videoWrapperId) 66 this.playerHTML = new PlayerHTML(videoWrapperId)
51 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) 67 this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin)
52 this.liveManager = new LiveManager(this.playerHTML) 68 this.liveManager = new LiveManager(this.playerHTML)
69 this.requiresPassword = false
53 70
54 try { 71 try {
55 this.config = JSON.parse((window as any)['PeerTubeServerConfig']) 72 this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
@@ -69,14 +86,14 @@ export class PeerTubeEmbed {
69 } 86 }
70 87
71 getScope () { 88 getScope () {
72 return this.playerManagerOptions.getScope() 89 return this.playerOptionsBuilder.getScope()
73 } 90 }
74 91
75 // --------------------------------------------------------------------------- 92 // ---------------------------------------------------------------------------
76 93
77 async init () { 94 async init () {
78 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) 95 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
79 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager') 96 this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
80 97
81 // Issue when we parsed config from HTML, fallback to API 98 // Issue when we parsed config from HTML, fallback to API
82 if (!this.config) { 99 if (!this.config) {
@@ -90,7 +107,7 @@ export class PeerTubeEmbed {
90 107
91 if (!videoId) return 108 if (!videoId) return
92 109
93 return this.loadVideoAndBuildPlayer({ uuid: videoId, autoplayFromPreviousVideo: false, forceAutoplay: false }) 110 return this.loadVideoAndBuildPlayer({ uuid: videoId, forceAutoplay: false })
94 } 111 }
95 112
96 private async initPlaylist () { 113 private async initPlaylist () {
@@ -125,7 +142,7 @@ export class PeerTubeEmbed {
125 } 142 }
126 143
127 private initializeApi () { 144 private initializeApi () {
128 if (this.playerManagerOptions.hasAPIEnabled()) { 145 if (this.playerOptionsBuilder.hasAPIEnabled()) {
129 if (this.api) { 146 if (this.api) {
130 this.api.reInit() 147 this.api.reInit()
131 return 148 return
@@ -147,7 +164,7 @@ export class PeerTubeEmbed {
147 164
148 this.playlistTracker.setCurrentElement(next) 165 this.playlistTracker.setCurrentElement(next)
149 166
150 return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) 167 return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, forceAutoplay: false })
151 } 168 }
152 169
153 async playPreviousPlaylistVideo () { 170 async playPreviousPlaylistVideo () {
@@ -159,7 +176,7 @@ export class PeerTubeEmbed {
159 176
160 this.playlistTracker.setCurrentElement(previous) 177 this.playlistTracker.setCurrentElement(previous)
161 178
162 await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) 179 await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, forceAutoplay: false })
163 } 180 }
164 181
165 getCurrentPlaylistPosition () { 182 getCurrentPlaylistPosition () {
@@ -170,123 +187,124 @@ export class PeerTubeEmbed {
170 187
171 private async loadVideoAndBuildPlayer (options: { 188 private async loadVideoAndBuildPlayer (options: {
172 uuid: string 189 uuid: string
173 autoplayFromPreviousVideo: boolean
174 forceAutoplay: boolean 190 forceAutoplay: boolean
175 }) { 191 }) {
176 const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options 192 const { uuid, forceAutoplay } = options
177 193
178 try { 194 try {
179 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid) 195 const {
196 videoResponse,
197 captionsPromise,
198 storyboardsPromise
199 } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
180 200
181 return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay }) 201 return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay })
182 } catch (err) { 202 } catch (err) {
183 this.playerHTML.displayError(err.message, await this.translationsPromise) 203
204 if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
205 else this.playerHTML.displayError(err.message, await this.translationsPromise)
184 } 206 }
185 } 207 }
186 208
187 private async buildVideoPlayer (options: { 209 private async buildVideoPlayer (options: {
188 videoResponse: Response 210 videoResponse: Response
211 storyboardsPromise: Promise<Response>
189 captionsPromise: Promise<Response> 212 captionsPromise: Promise<Response>
190 autoplayFromPreviousVideo: boolean
191 forceAutoplay: boolean 213 forceAutoplay: boolean
192 }) { 214 }) {
193 const { videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay } = options 215 const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options
194
195 this.resetPlayerElement()
196 216
197 const videoInfoPromise = videoResponse.json() 217 const videoInfoPromise = videoResponse.json()
198 .then(async (videoInfo: VideoDetails) => { 218 .then(async (videoInfo: VideoDetails) => {
199 this.playerManagerOptions.loadParams(this.config, videoInfo) 219 this.playerOptionsBuilder.loadParams(this.config, videoInfo)
200 220
201 if (!autoplayFromPreviousVideo && !this.playerManagerOptions.hasAutoplay()) {
202 this.playerHTML.buildPlaceholder(videoInfo)
203 }
204 const live = videoInfo.isLive 221 const live = videoInfo.isLive
205 ? await this.videoFetcher.loadLive(videoInfo) 222 ? await this.videoFetcher.loadLive(videoInfo)
206 : undefined 223 : undefined
207 224
208 const videoFileToken = videoRequiresAuth(videoInfo) 225 const videoFileToken = videoRequiresFileToken(videoInfo)
209 ? await this.videoFetcher.loadVideoToken(videoInfo) 226 ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
210 : undefined 227 : undefined
211 228
212 return { live, video: videoInfo, videoFileToken } 229 return { live, video: videoInfo, videoFileToken }
213 }) 230 })
214 231
215 const [ { video, live, videoFileToken }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ 232 const [
233 { video, live, videoFileToken },
234 translations,
235 captionsResponse,
236 storyboardsResponse
237 ] = await Promise.all([
216 videoInfoPromise, 238 videoInfoPromise,
217 this.translationsPromise, 239 this.translationsPromise,
218 captionsPromise, 240 captionsPromise,
219 this.PeertubePlayerManagerModulePromise 241 storyboardsPromise,
242 this.buildPlayerIfNeeded()
220 ]) 243 ])
221 244
222 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
250 const playlist = this.playlistTracker
251 ? {
252 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, forceAutoplay: false }),
223 253
224 const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager 254 playlistTracker: this.playlistTracker,
255 playNext: () => this.playNextPlaylistVideo(),
256 playPrevious: () => this.playPreviousPlaylistVideo()
257 }
258 : undefined
225 259
226 const playerOptions = await this.playerManagerOptions.getPlayerOptions({ 260 const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
227 video, 261 video,
228 captionsResponse, 262 captionsResponse,
229 autoplayFromPreviousVideo,
230 translations, 263 translations,
231 serverConfig: this.config,
232 264
233 authorizationHeader: () => this.http.getHeaderTokenValue(), 265 storyboardsResponse,
234 videoFileToken: () => videoFileToken,
235 266
236 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), 267 videoFileToken: () => videoFileToken,
268 videoPassword: () => this.videoPassword,
269 requiresPassword: this.requiresPassword,
237 270
238 playlistTracker: this.playlistTracker, 271 playlist,
239 playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
240 playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
241 272
242 live, 273 live,
243 forceAutoplay 274 forceAutoplay,
275 alreadyPlayed: this.alreadyPlayed
244 }) 276 })
277 await this.peertubePlayer.load(loadOptions)
245 278
246 this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), playerOptions, (player: videojs.Player) => { 279 if (!this.alreadyInitialized) {
247 this.player = player 280 this.player = this.peertubePlayer.getPlayer();
248 })
249 281
250 this.player.on('customError', (event: any, data: any) => { 282 (window as any)['videojsPlayer'] = this.player
251 const message = data?.err?.message || ''
252 if (!message.includes('from xs param')) return
253 283
254 this.player.dispose() 284 this.buildCSS()
255 this.playerHTML.removePlayerElement() 285 this.initializeApi()
256 this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations) 286 }
257 });
258
259 (window as any)['videojsPlayer'] = this.player
260
261 this.buildCSS()
262 this.buildPlayerDock(video)
263 this.initializeApi()
264 287
265 this.playerHTML.removePlaceholder() 288 this.alreadyInitialized = true
266 289
267 if (this.isPlaylistEmbed()) { 290 this.player.one('play', () => {
268 await this.buildPlayerPlaylistUpnext() 291 this.alreadyPlayed = true
292 })
269 293
270 this.player.playlist().updateSelected() 294 if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
271
272 this.player.on('stopped', () => {
273 this.playNextPlaylistVideo()
274 })
275 }
276 295
277 if (video.isLive) { 296 if (video.isLive) {
278 this.liveManager.listenForChanges({ 297 this.liveManager.listenForChanges({
279 video, 298 video,
280 onPublishedVideo: () => { 299 onPublishedVideo: () => {
281 this.liveManager.stopListeningForChanges(video) 300 this.liveManager.stopListeningForChanges(video)
282 this.loadVideoAndBuildPlayer({ uuid: video.uuid, autoplayFromPreviousVideo: false, forceAutoplay: true }) 301 this.loadVideoAndBuildPlayer({ uuid: video.uuid, forceAutoplay: true })
283 } 302 }
284 }) 303 })
285 304
286 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) {
287 this.liveManager.displayInfo({ state: video.state.id, translations }) 306 this.liveManager.displayInfo({ state: video.state.id, translations })
288 307 this.peertubePlayer.disable()
289 this.disablePlayer()
290 } else { 308 } else {
291 this.correctlyHandleLiveEnding(translations) 309 this.correctlyHandleLiveEnding(translations)
292 } 310 }
@@ -295,74 +313,15 @@ export class PeerTubeEmbed {
295 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 })
296 } 314 }
297 315
298 private resetPlayerElement () {
299 if (this.player) {
300 this.player.dispose()
301 this.player = undefined
302 }
303
304 const playerElement = document.createElement('video')
305 playerElement.className = 'video-js vjs-peertube-skin'
306 playerElement.setAttribute('playsinline', 'true')
307
308 this.playerHTML.setPlayerElement(playerElement)
309 this.playerHTML.addPlayerElementToDOM()
310 }
311
312 private async buildPlayerPlaylistUpnext () {
313 const translations = await this.translationsPromise
314
315 this.player.upnext({
316 timeout: 10000, // 10s
317 headText: peertubeTranslate('Up Next', translations),
318 cancelText: peertubeTranslate('Cancel', translations),
319 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
320 getTitle: () => this.playlistTracker.nextVideoTitle(),
321 next: () => this.playNextPlaylistVideo(),
322 condition: () => !!this.playlistTracker.getNextPlaylistElement(),
323 suspended: () => false
324 })
325 }
326
327 private buildPlayerDock (videoInfo: VideoDetails) {
328 if (!this.playerManagerOptions.hasControls()) return
329
330 // On webtorrent fallback, player may have been disposed
331 if (!this.player.player_) return
332
333 const title = this.playerManagerOptions.hasTitle()
334 ? videoInfo.name
335 : undefined
336
337 const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled()
338 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
339 : undefined
340
341 if (!title && !description) return
342
343 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
344 const avatar = availableAvatars.length !== 0
345 ? availableAvatars[0]
346 : undefined
347
348 this.player.peertubeDock({
349 title,
350 description,
351 avatarUrl: title && avatar
352 ? avatar.path
353 : undefined
354 })
355 }
356
357 private buildCSS () { 316 private buildCSS () {
358 const body = document.getElementById('custom-css') 317 const body = document.getElementById('custom-css')
359 318
360 if (this.playerManagerOptions.hasBigPlayBackgroundColor()) { 319 if (this.playerOptionsBuilder.hasBigPlayBackgroundColor()) {
361 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor()) 320 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerOptionsBuilder.getBigPlayBackgroundColor())
362 } 321 }
363 322
364 if (this.playerManagerOptions.hasForegroundColor()) { 323 if (this.playerOptionsBuilder.hasForegroundColor()) {
365 body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor()) 324 body.style.setProperty('--embedForegroundColor', this.playerOptionsBuilder.getForegroundColor())
366 } 325 }
367 } 326 }
368 327
@@ -384,23 +343,52 @@ export class PeerTubeEmbed {
384 // Display the live ended information 343 // Display the live ended information
385 this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations }) 344 this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations })
386 345
387 this.disablePlayer() 346 this.peertubePlayer.disable()
388 }) 347 })
389 } 348 }
390 349
391 private disablePlayer () { 350 private async handlePasswordError (err: PeerTubeServerError) {
392 if (this.player.isFullscreen()) { 351 let incorrectPassword: boolean = null
393 this.player.exitFullscreen() 352 if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
394 } 353 else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true
395 354
396 // Disable player 355 if (incorrectPassword === null) return false
397 this.player.hasStarted(false)
398 this.player.removeClass('vjs-has-autoplay')
399 this.player.bigPlayButton.hide();
400 356
401 (this.player.el() as HTMLElement).style.pointerEvents = 'none' 357 this.requiresPassword = true
358 this.videoPassword = await this.playerHTML.askVideoPassword({
359 incorrectPassword,
360 translations: await this.translationsPromise
361 })
362 return true
402 } 363 }
403 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 }
404} 392}
405 393
406PeerTubeEmbed.main() 394PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
index 95e3b029e..c1e9f7750 100644
--- a/client/src/standalone/videos/shared/auth-http.ts
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -18,10 +18,12 @@ export class AuthHTTP {
18 if (this.userOAuthTokens) this.setHeadersFromTokens() 18 if (this.userOAuthTokens) this.setHeadersFromTokens()
19 } 19 }
20 20
21 fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { 21 fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) {
22 const refreshFetchOptions = optionalAuth 22 let refreshFetchOptions: { headers?: Headers } = {}
23 ? { headers: this.headers } 23
24 : {} 24 if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword)
25
26 if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers }
25 27
26 return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) 28 return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
27 } 29 }
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'
2export * from './peertube-plugin' 2export * from './peertube-plugin'
3export * from './live-manager' 3export * from './live-manager'
4export * from './player-html' 4export * from './player-html'
5export * from './player-manager-options' 5export * from './player-options-builder'
6export * from './playlist-fetcher' 6export * from './playlist-fetcher'
7export * from './playlist-tracker' 7export * from './playlist-tracker'
8export * from './translations' 8export * from './translations'
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
index d93678c10..0defa0d70 100644
--- a/client/src/standalone/videos/shared/player-html.ts
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -1,5 +1,4 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' 1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { VideoDetails } from '../../../../../shared/models'
3import { logger } from '../../../root-helpers' 2import { logger } from '../../../root-helpers'
4import { Translations } from './translations' 3import { Translations } from './translations'
5 4
@@ -55,17 +54,55 @@ export class PlayerHTML {
55 this.wrapperElement.style.display = 'none' 54 this.wrapperElement.style.display = 'none'
56 } 55 }
57 56
58 buildPlaceholder (video: VideoDetails) { 57 async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise<string> {
59 const placeholder = this.getPlaceholderElement() 58 const { incorrectPassword, translations } = options
59 return new Promise((resolve) => {
60 60
61 const url = window.location.origin + video.previewPath 61 this.wrapperElement.style.display = 'none'
62 placeholder.style.backgroundImage = `url("${url}")` 62
63 placeholder.style.display = 'block' 63 const translatedTitle = peertubeTranslate('This video is password protected', translations)
64 const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations)
65
66 document.title = translatedTitle
67
68 const videoPasswordBlock = document.getElementById('video-password-block')
69 videoPasswordBlock.style.display = 'flex'
70
71 const videoPasswordTitle = document.getElementById('video-password-title')
72 videoPasswordTitle.innerHTML = translatedTitle
73
74 const videoPasswordMessage = document.getElementById('video-password-content')
75 videoPasswordMessage.innerHTML = translatedMessage
76
77 if (incorrectPassword) {
78 const videoPasswordError = document.getElementById('video-password-error')
79 videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations)
80 videoPasswordError.style.transform = 'scale(1.2)'
81
82 setTimeout(() => {
83 videoPasswordError.style.transform = 'scale(1)'
84 }, 500)
85 }
86
87 const videoPasswordSubmitButton = document.getElementById('video-password-submit')
88 videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations)
89
90 const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement
91 videoPasswordInput.placeholder = peertubeTranslate('Password', translations)
92
93 const videoPasswordForm = document.getElementById('video-password-form')
94 videoPasswordForm.addEventListener('submit', (event) => {
95 event.preventDefault()
96 const videoPassword = videoPasswordInput.value
97 resolve(videoPassword)
98 })
99 })
64 } 100 }
65 101
66 removePlaceholder () { 102 removeVideoPasswordBlock () {
67 const placeholder = this.getPlaceholderElement() 103 const videoPasswordBlock = document.getElementById('video-password-block')
68 placeholder.style.display = 'none' 104 videoPasswordBlock.style.display = 'none'
105 this.wrapperElement.style.display = 'block'
69 } 106 }
70 107
71 displayInformation (text: string, translations: Translations) { 108 displayInformation (text: string, translations: Translations) {
@@ -85,10 +122,6 @@ export class PlayerHTML {
85 this.informationElement = undefined 122 this.informationElement = undefined
86 } 123 }
87 124
88 private getPlaceholderElement () {
89 return document.getElementById('placeholder-preview')
90 }
91
92 private removeElement (element: HTMLElement) { 125 private removeElement (element: HTMLElement) {
93 element.parentElement.removeChild(element) 126 element.parentElement.removeChild(element)
94 } 127 }
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-options-builder.ts
index 43ae22a3b..8a4e32444 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-options-builder.ts
@@ -2,6 +2,7 @@ import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { 2import {
3 HTMLServerConfig, 3 HTMLServerConfig,
4 LiveVideo, 4 LiveVideo,
5 Storyboard,
5 Video, 6 Video,
6 VideoCaption, 7 VideoCaption,
7 VideoDetails, 8 VideoDetails,
@@ -9,7 +10,7 @@ import {
9 VideoState, 10 VideoState,
10 VideoStreamingPlaylistType 11 VideoStreamingPlaylistType
11} from '../../../../../shared/models' 12} from '../../../../../shared/models'
12import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' 13import { HLSOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerMode, VideoJSCaption } from '../../../assets/player'
13import { 14import {
14 getBoolOrDefault, 15 getBoolOrDefault,
15 getParamString, 16 getParamString,
@@ -18,7 +19,7 @@ import {
18 logger, 19 logger,
19 peertubeLocalStorage, 20 peertubeLocalStorage,
20 UserLocalStorageKeys, 21 UserLocalStorageKeys,
21 videoRequiresAuth 22 videoRequiresUserAuth
22} from '../../../root-helpers' 23} from '../../../root-helpers'
23import { PeerTubePlugin } from './peertube-plugin' 24import { PeerTubePlugin } from './peertube-plugin'
24import { PlayerHTML } from './player-html' 25import { PlayerHTML } from './player-html'
@@ -26,7 +27,7 @@ import { PlaylistTracker } from './playlist-tracker'
26import { Translations } from './translations' 27import { Translations } from './translations'
27import { VideoFetcher } from './video-fetcher' 28import { VideoFetcher } from './video-fetcher'
28 29
29export class PlayerManagerOptions { 30export class PlayerOptionsBuilder {
30 private autoplay: boolean 31 private autoplay: boolean
31 32
32 private controls: boolean 33 private controls: boolean
@@ -140,10 +141,10 @@ export class PlayerManagerOptions {
140 141
141 if (modeParam) { 142 if (modeParam) {
142 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' 143 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
143 else this.mode = 'webtorrent' 144 else this.mode = 'web-video'
144 } else { 145 } else {
145 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'
146 else this.mode = 'webtorrent' 147 else this.mode = 'web-video'
147 } 148 }
148 } catch (err) { 149 } catch (err) {
149 logger.error('Cannot get params from URL.', err) 150 logger.error('Cannot get params from URL.', err)
@@ -152,119 +153,140 @@ export class PlayerManagerOptions {
152 153
153 // --------------------------------------------------------------------------- 154 // ---------------------------------------------------------------------------
154 155
155 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: {
156 video: VideoDetails 197 video: VideoDetails
157 captionsResponse: Response 198 captionsResponse: Response
199
200 storyboardsResponse: Response
201
158 live?: LiveVideo 202 live?: LiveVideo
159 203
204 alreadyPlayed: boolean
160 forceAutoplay: boolean 205 forceAutoplay: boolean
161 206
162 authorizationHeader: () => string
163 videoFileToken: () => string 207 videoFileToken: () => string
164 208
165 serverConfig: HTMLServerConfig 209 videoPassword: () => string
166 210 requiresPassword: boolean
167 autoplayFromPreviousVideo: boolean
168 211
169 translations: Translations 212 translations: Translations
170 213
171 playlistTracker?: PlaylistTracker 214 playlist?: {
172 playNextPlaylistVideo?: () => any 215 playlistTracker: PlaylistTracker
173 playPreviousPlaylistVideo?: () => any 216 playNext: () => any
174 onVideoUpdate?: (uuid: string) => any 217 playPrevious: () => any
175 }) { 218 onVideoUpdate: (uuid: string) => any
219 }
220 }): Promise<PeerTubePlayerLoadOptions> {
176 const { 221 const {
177 video, 222 video,
178 captionsResponse, 223 captionsResponse,
179 autoplayFromPreviousVideo,
180 videoFileToken, 224 videoFileToken,
225 videoPassword,
226 requiresPassword,
181 translations, 227 translations,
228 alreadyPlayed,
182 forceAutoplay, 229 forceAutoplay,
183 playlistTracker, 230 playlist,
184 live, 231 live,
185 authorizationHeader, 232 storyboardsResponse
186 serverConfig
187 } = options 233 } = options
188 234
189 const videoCaptions = await this.buildCaptions(captionsResponse, translations) 235 const [ videoCaptions, storyboard ] = await Promise.all([
190 236 this.buildCaptions(captionsResponse, translations),
191 const playerOptions: PeertubePlayerManagerOptions = { 237 this.buildStoryboard(storyboardsResponse)
192 common: { 238 ])
193 // Autoplay in playlist mode
194 autoplay: autoplayFromPreviousVideo ? true : this.autoplay,
195 forceAutoplay,
196 239
197 controls: this.controls, 240 return {
198 controlBar: this.controlBar, 241 mode: this.mode,
199
200 muted: this.muted,
201 loop: this.loop,
202 242
203 p2pEnabled: this.p2pEnabled, 243 autoplay: forceAutoplay || alreadyPlayed || this.autoplay,
244 forceAutoplay,
204 245
205 captions: videoCaptions.length !== 0, 246 p2pEnabled: this.p2pEnabled,
206 subtitle: this.subtitle,
207 247
208 startTime: playlistTracker 248 subtitle: this.subtitle,
209 ? playlistTracker.getCurrentElement().startTimestamp
210 : this.startTime,
211 stopTime: playlistTracker
212 ? playlistTracker.getCurrentElement().stopTimestamp
213 : this.stopTime,
214 249
215 playbackRate: this.playbackRate, 250 storyboard,
216 251
217 videoCaptions, 252 startTime: playlist
218 inactivityTimeout: 2500, 253 ? playlist.playlistTracker.getCurrentElement().startTimestamp
219 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), 254 : this.startTime,
220 videoViewIntervalMs: 5000, 255 stopTime: playlist
221 metricsUrl: window.location.origin + '/api/v1/metrics/playback', 256 ? playlist.playlistTracker.getCurrentElement().stopTimestamp
257 : this.stopTime,
222 258
223 videoShortUUID: video.shortUUID, 259 videoCaptions,
224 videoUUID: video.uuid, 260 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
225 261
226 playerElement: this.playerHTML.getPlayerElement(), 262 videoShortUUID: video.shortUUID,
227 onPlayerElementChange: (element: HTMLVideoElement) => { 263 videoUUID: video.uuid,
228 this.playerHTML.setPlayerElement(element)
229 },
230 264
231 videoDuration: video.duration, 265 duration: video.duration,
232 enableHotkeys: true,
233 266
234 peertubeLink: this.peertubeLink, 267 poster: window.location.origin + video.previewPath,
235 instanceName: serverConfig.instance.name,
236 268
237 poster: window.location.origin + video.previewPath, 269 embedUrl: window.location.origin + video.embedPath,
238 theaterButton: false, 270 embedTitle: video.name,
239 271
240 serverUrl: window.location.origin, 272 requiresUserAuth: videoRequiresUserAuth(video),
241 language: navigator.language, 273 videoFileToken,
242 embedUrl: window.location.origin + video.embedPath,
243 embedTitle: video.name,
244 274
245 requiresAuth: videoRequiresAuth(video), 275 requiresPassword,
246 authorizationHeader, 276 videoPassword,
247 videoFileToken,
248 277
249 errorNotifier: () => { 278 ...this.buildLiveOptions(video, live),
250 // Empty, we don't have a notifier in the embed
251 },
252 279
253 ...this.buildLiveOptions(video, live), 280 ...this.buildPlaylistOptions(playlist),
254 281
255 ...this.buildPlaylistOptions(options) 282 dock: this.buildDockOptions(video),
256 },
257 283
258 webtorrent: { 284 webVideo: {
259 videoFiles: video.files 285 videoFiles: video.files
260 }, 286 },
261 287
262 ...this.buildP2PMediaLoaderOptions(video), 288 hls: this.buildHLSOptions(video)
263
264 pluginsManager: this.peertubePlugin.getPluginsManager()
265 } 289 }
266
267 return playerOptions
268 } 290 }
269 291
270 private buildLiveOptions (video: VideoDetails, live: LiveVideo) { 292 private buildLiveOptions (video: VideoDetails, live: LiveVideo) {
@@ -278,15 +300,39 @@ export class PlayerManagerOptions {
278 } 300 }
279 } 301 }
280 302
281 private buildPlaylistOptions (options: { 303 private async buildStoryboard (storyboardsResponse: Response) {
282 playlistTracker?: PlaylistTracker 304 const { storyboards } = await storyboardsResponse.json() as { storyboards: Storyboard[] }
283 playNextPlaylistVideo?: () => any 305 if (!storyboards || storyboards.length === 0) return undefined
284 playPreviousPlaylistVideo?: () => any 306
285 onVideoUpdate?: (uuid: string) => any 307 return {
308 url: window.location.origin + storyboards[0].storyboardPath,
309 height: storyboards[0].spriteHeight,
310 width: storyboards[0].spriteWidth,
311 interval: storyboards[0].spriteDuration
312 }
313 }
314
315 private buildPlaylistOptions (options?: {
316 playlistTracker: PlaylistTracker
317 playNext: () => any
318 playPrevious: () => any
319 onVideoUpdate: (uuid: string) => any
286 }) { 320 }) {
287 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 }
288 334
289 if (!playlistTracker) return {} 335 const { playlistTracker, playNext, playPrevious, onVideoUpdate } = options
290 336
291 return { 337 return {
292 playlist: { 338 playlist: {
@@ -302,27 +348,37 @@ export class PlayerManagerOptions {
302 } 348 }
303 }, 349 },
304 350
305 nextVideo: () => playNextPlaylistVideo(), 351 previousVideo: {
306 hasNextVideo: () => playlistTracker.hasNextPlaylistElement(), 352 enabled: playlistTracker.hasPreviousPlaylistElement(),
353 handler: () => playPrevious(),
354 displayControlBarButton: true
355 },
307 356
308 previousVideo: () => playPreviousPlaylistVideo(), 357 nextVideo: {
309 hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement() 358 enabled: playlistTracker.hasNextPlaylistElement(),
359 handler: () => playNext(),
360 getVideoTitle: () => playlistTracker.getNextPlaylistElement()?.video?.name,
361 displayControlBarButton: true
362 },
363
364 upnext: {
365 isEnabled: () => true,
366 isSuspended: () => false,
367 timeout: 0
368 }
310 } 369 }
311 } 370 }
312 371
313 private buildP2PMediaLoaderOptions (video: VideoDetails) { 372 private buildHLSOptions (video: VideoDetails): HLSOptions {
314 if (this.mode !== 'p2p-media-loader') return {}
315
316 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
317 375
318 return { 376 return {
319 p2pMediaLoader: { 377 playlistUrl: hlsPlaylist.playlistUrl,
320 playlistUrl: hlsPlaylist.playlistUrl, 378 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
321 segmentsSha256Url: hlsPlaylist.segmentsSha256Url, 379 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
322 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), 380 trackerAnnounce: video.trackerUrls,
323 trackerAnnounce: video.trackerUrls, 381 videoFiles: hlsPlaylist.files
324 videoFiles: hlsPlaylist.files
325 } as P2PMediaLoaderOptions
326 } 382 }
327 } 383 }
328 384
@@ -344,6 +400,35 @@ export class PlayerManagerOptions {
344 400
345 // --------------------------------------------------------------------------- 401 // ---------------------------------------------------------------------------
346 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
347 private isP2PEnabled (config: HTMLServerConfig, video: Video) { 432 private isP2PEnabled (config: HTMLServerConfig, video: Video) {
348 const userP2PEnabled = getBoolOrDefault( 433 const userP2PEnabled = getBoolOrDefault(
349 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), 434 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
index cf6d12831..7fb94fbf3 100644
--- a/client/src/standalone/videos/shared/video-fetcher.ts
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -1,5 +1,6 @@
1import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' 1import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
2import { logger } from '../../../root-helpers' 2import { logger } from '../../../root-helpers'
3import { PeerTubeServerError } from '../../../types'
3import { AuthHTTP } from './auth-http' 4import { AuthHTTP } from './auth-http'
4 5
5export class VideoFetcher { 6export class VideoFetcher {
@@ -8,8 +9,8 @@ export class VideoFetcher {
8 9
9 } 10 }
10 11
11 async loadVideo (videoId: string) { 12 async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
12 const videoPromise = this.loadVideoInfo(videoId) 13 const videoPromise = this.loadVideoInfo({ videoId, videoPassword })
13 14
14 let videoResponse: Response 15 let videoResponse: Response
15 let isResponseOk: boolean 16 let isResponseOk: boolean
@@ -27,13 +28,17 @@ export class VideoFetcher {
27 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { 28 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
28 throw new Error('This video does not exist.') 29 throw new Error('This video does not exist.')
29 } 30 }
30 31 if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) {
32 const res = await videoResponse.json()
33 throw new PeerTubeServerError(res.message, res.code)
34 }
31 throw new Error('We cannot fetch the video. Please try again later.') 35 throw new Error('We cannot fetch the video. Please try again later.')
32 } 36 }
33 37
34 const captionsPromise = this.loadVideoCaptions(videoId) 38 const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
39 const storyboardsPromise = this.loadStoryboards(videoId)
35 40
36 return { captionsPromise, videoResponse } 41 return { captionsPromise, storyboardsPromise, videoResponse }
37 } 42 }
38 43
39 loadLive (video: VideoDetails) { 44 loadLive (video: VideoDetails) {
@@ -41,8 +46,8 @@ export class VideoFetcher {
41 .then(res => res.json() as Promise<LiveVideo>) 46 .then(res => res.json() as Promise<LiveVideo>)
42 } 47 }
43 48
44 loadVideoToken (video: VideoDetails) { 49 loadVideoToken (video: VideoDetails, videoPassword?: string) {
45 return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) 50 return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword)
46 .then(res => res.json() as Promise<VideoToken>) 51 .then(res => res.json() as Promise<VideoToken>)
47 .then(token => token.files.token) 52 .then(token => token.files.token)
48 } 53 }
@@ -51,12 +56,12 @@ export class VideoFetcher {
51 return this.getVideoUrl(videoUUID) + '/views' 56 return this.getVideoUrl(videoUUID) + '/views'
52 } 57 }
53 58
54 private loadVideoInfo (videoId: string): Promise<Response> { 59 private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
55 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) 60 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
56 } 61 }
57 62
58 private loadVideoCaptions (videoId: string): Promise<Response> { 63 private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
59 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) 64 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
60 } 65 }
61 66
62 private getVideoUrl (id: string) { 67 private getVideoUrl (id: string) {
@@ -67,6 +72,14 @@ export class VideoFetcher {
67 return window.location.origin + '/api/v1/videos/live/' + videoId 72 return window.location.origin + '/api/v1/videos/live/' + videoId
68 } 73 }
69 74
75 private loadStoryboards (videoUUID: string): Promise<Response> {
76 return this.http.fetch(this.getStoryboardsUrl(videoUUID), { optionalAuth: true })
77 }
78
79 private getStoryboardsUrl (videoId: string) {
80 return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards'
81 }
82
70 private getVideoTokenUrl (id: string) { 83 private getVideoTokenUrl (id: string) {
71 return this.getVideoUrl(id) + '/token' 84 return this.getVideoUrl(id) + '/token'
72 } 85 }
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 @@
1import './test-embed.scss' 1import './test-embed.scss'
2import { PeerTubeResolution, PlayerEventType } from '../player/definitions' 2import { PeerTubeResolution, PlayerEventType } from '../embed-player-api/definitions'
3import { PeerTubePlayer } from '../player/player' 3import { PeerTubePlayer } from '../embed-player-api/player'
4import { logger } from '../../root-helpers' 4import { logger } from '../../root-helpers'
5 5
6window.addEventListener('load', async () => { 6window.addEventListener('load', async () => {
diff --git a/client/src/types/index.ts b/client/src/types/index.ts
index 5508515fd..60564496c 100644
--- a/client/src/types/index.ts
+++ b/client/src/types/index.ts
@@ -1,4 +1,5 @@
1export * from './client-script.model' 1export * from './client-script.model'
2export * from './server-error.model'
2export * from './job-state-client.type' 3export * from './job-state-client.type'
3export * from './job-type-client.type' 4export * from './job-type-client.type'
4export * from './link.type' 5export * from './link.type'
diff --git a/client/src/types/server-error.model.ts b/client/src/types/server-error.model.ts
new file mode 100644
index 000000000..4a57287fe
--- /dev/null
+++ b/client/src/types/server-error.model.ts
@@ -0,0 +1,11 @@
1import { ServerErrorCode } from '@shared/models/index'
2
3export class PeerTubeServerError extends Error {
4 serverCode: ServerErrorCode
5
6 constructor (message: string, serverCode: ServerErrorCode) {
7 super(message)
8 this.name = 'CustomError'
9 this.serverCode = serverCode
10 }
11}