aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app')
-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
69 files changed, 869 insertions, 532 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 }