diff options
author | Chocobozzz <me@florianbigard.com> | 2023-08-28 10:55:04 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-08-28 16:17:31 +0200 |
commit | 77b70702d2193d78bf6fbd07f0fc7335e34957f8 (patch) | |
tree | 1a0aed540054286c9a8b10c4890cc0f718e00458 /client/src | |
parent | 7113f32a87bd6b2868154fed20bde1a1633c190e (diff) | |
download | PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.gz PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.zst PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.zip |
Add video chapters support
Diffstat (limited to 'client/src')
41 files changed, 646 insertions, 102 deletions
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 f3c1f1634..8342562c3 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 | |||
@@ -230,6 +230,57 @@ | |||
230 | </ng-template> | 230 | </ng-template> |
231 | </ng-container> | 231 | </ng-container> |
232 | 232 | ||
233 | <ng-container ngbNavItem *ngIf="!liveVideo"> | ||
234 | <a ngbNavLink i18n>Chapters</a> | ||
235 | |||
236 | <ng-template ngbNavContent> | ||
237 | <div class="row mb-5"> | ||
238 | <div class="chapters col-md-12 col-xl-6" formArrayName="chapters"> | ||
239 | <ng-container *ngFor="let chapterControl of getChaptersFormArray().controls; let i = index"> | ||
240 | <div class="chapter" [formGroupName]="i"> | ||
241 | <!-- Row 1 --> | ||
242 | <div></div> | ||
243 | |||
244 | <label i18n [ngClass]="{ 'hide-chapter-label': i !== 0 }" [for]="'timecode[' + i + ']'">Timecode</label> | ||
245 | |||
246 | <label i18n [ngClass]="{ 'hide-chapter-label': i !== 0 }" [for]="'title[' + i + ']'">Chapter name</label> | ||
247 | |||
248 | <div></div> | ||
249 | |||
250 | <!-- Row 2 --> | ||
251 | <div class="position">{{ i + 1 }}</div> | ||
252 | |||
253 | <my-timestamp-input | ||
254 | class="d-block" [disableBorder]="false" [inputName]="'timecode[' + i + ']'" | ||
255 | [maxTimestamp]="videoToUpdate?.duration" formControlName="timecode" | ||
256 | ></my-timestamp-input> | ||
257 | |||
258 | <div> | ||
259 | <input | ||
260 | [ngClass]="{ 'input-error': formErrors.chapters[i].title }" | ||
261 | type="text" [id]="'title[' + i + ']'" [name]="'title[' + i + ']'" formControlName="title" | ||
262 | /> | ||
263 | |||
264 | <div [ngClass]="{ 'opacity-0': !formErrors.chapters[i].title }" class="form-error"> | ||
265 | <span class="opacity-0">t</span> <!-- Ensure we have reserve a correct height --> | ||
266 | {{ formErrors.chapters[i].title }} | ||
267 | </div> | ||
268 | </div> | ||
269 | |||
270 | <my-delete-button *ngIf="!isLastChapterControl(i)" (click)="deleteChapterControl(i)"></my-delete-button> | ||
271 | </div> | ||
272 | </ng-container> | ||
273 | |||
274 | <div *ngIf="getChapterArrayErrors()" class="form-error"> | ||
275 | {{ getChapterArrayErrors() }} | ||
276 | </div> | ||
277 | </div> | ||
278 | |||
279 | <my-embed *ngIf="videoToUpdate" class="col-md-12 col-xl-6" [video]="videoToUpdate"></my-embed> | ||
280 | </div> | ||
281 | </ng-template> | ||
282 | </ng-container> | ||
283 | |||
233 | <ng-container ngbNavItem *ngIf="liveVideo"> | 284 | <ng-container ngbNavItem *ngIf="liveVideo"> |
234 | <a ngbNavLink i18n>Live settings</a> | 285 | <a ngbNavLink i18n>Live settings</a> |
235 | 286 | ||
@@ -312,7 +363,6 @@ | |||
312 | 363 | ||
313 | </ng-container> | 364 | </ng-container> |
314 | 365 | ||
315 | |||
316 | <ng-container ngbNavItem> | 366 | <ng-container ngbNavItem> |
317 | <a ngbNavLink i18n>Advanced settings</a> | 367 | <a ngbNavLink i18n>Advanced settings</a> |
318 | 368 | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss index b0c053019..a81d62dd1 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss | |||
@@ -117,6 +117,32 @@ p-calendar { | |||
117 | @include orange-button; | 117 | @include orange-button; |
118 | } | 118 | } |
119 | 119 | ||
120 | .hide-chapter-label { | ||
121 | height: 0; | ||
122 | opacity: 0; | ||
123 | } | ||
124 | |||
125 | .chapter { | ||
126 | display: grid; | ||
127 | grid-template-columns: auto auto minmax(150px, 350px) 1fr; | ||
128 | grid-template-rows: auto auto; | ||
129 | column-gap: 1rem; | ||
130 | |||
131 | .position { | ||
132 | height: 31px; | ||
133 | display: flex; | ||
134 | align-items: center; | ||
135 | } | ||
136 | |||
137 | my-delete-button { | ||
138 | width: fit-content; | ||
139 | } | ||
140 | |||
141 | .form-error { | ||
142 | margin-top: 0; | ||
143 | } | ||
144 | } | ||
145 | |||
120 | @include on-small-main-col { | 146 | @include on-small-main-col { |
121 | .form-columns { | 147 | .form-columns { |
122 | grid-template-columns: 1fr; | 148 | grid-template-columns: 1fr; |
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 898d3b0a6..35beba5b1 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 | |||
@@ -2,10 +2,10 @@ import { forkJoin } from 'rxjs' | |||
2 | import { map } from 'rxjs/operators' | 2 | import { map } from 'rxjs/operators' |
3 | import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' | 3 | import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' |
4 | import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' | 4 | import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' |
5 | import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' | 5 | import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms' |
6 | import { HooksService, PluginService, ServerService } from '@app/core' | 6 | import { HooksService, PluginService, ServerService } from '@app/core' |
7 | import { removeElementFromArray } from '@app/helpers' | 7 | import { removeElementFromArray } from '@app/helpers' |
8 | import { BuildFormValidator } from '@app/shared/form-validators' | 8 | import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators' |
9 | import { | 9 | import { |
10 | VIDEO_CATEGORY_VALIDATOR, | 10 | VIDEO_CATEGORY_VALIDATOR, |
11 | VIDEO_CHANNEL_VALIDATOR, | 11 | VIDEO_CHANNEL_VALIDATOR, |
@@ -20,9 +20,10 @@ import { | |||
20 | VIDEO_SUPPORT_VALIDATOR, | 20 | VIDEO_SUPPORT_VALIDATOR, |
21 | VIDEO_TAGS_ARRAY_VALIDATOR | 21 | VIDEO_TAGS_ARRAY_VALIDATOR |
22 | } from '@app/shared/form-validators/video-validators' | 22 | } from '@app/shared/form-validators/video-validators' |
23 | import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' | 23 | import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' |
24 | import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' | ||
24 | import { InstanceService } from '@app/shared/shared-instance' | 25 | import { InstanceService } from '@app/shared/shared-instance' |
25 | import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main' | 26 | import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main' |
26 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 27 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
27 | import { | 28 | import { |
28 | HTMLServerConfig, | 29 | HTMLServerConfig, |
@@ -30,6 +31,7 @@ import { | |||
30 | LiveVideoLatencyMode, | 31 | LiveVideoLatencyMode, |
31 | RegisterClientFormFieldOptions, | 32 | RegisterClientFormFieldOptions, |
32 | RegisterClientVideoFieldOptions, | 33 | RegisterClientVideoFieldOptions, |
34 | VideoChapter, | ||
33 | VideoConstant, | 35 | VideoConstant, |
34 | VideoDetails, | 36 | VideoDetails, |
35 | VideoPrivacy, | 37 | VideoPrivacy, |
@@ -57,7 +59,7 @@ type PluginField = { | |||
57 | }) | 59 | }) |
58 | export class VideoEditComponent implements OnInit, OnDestroy { | 60 | export class VideoEditComponent implements OnInit, OnDestroy { |
59 | @Input() form: FormGroup | 61 | @Input() form: FormGroup |
60 | @Input() formErrors: { [ id: string ]: string } = {} | 62 | @Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {} |
61 | @Input() validationMessages: FormReactiveValidationMessages = {} | 63 | @Input() validationMessages: FormReactiveValidationMessages = {} |
62 | 64 | ||
63 | @Input() videoToUpdate: VideoDetails | 65 | @Input() videoToUpdate: VideoDetails |
@@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
68 | @Input() videoCaptions: VideoCaptionWithPathEdit[] = [] | 70 | @Input() videoCaptions: VideoCaptionWithPathEdit[] = [] |
69 | @Input() videoSource: VideoSource | 71 | @Input() videoSource: VideoSource |
70 | 72 | ||
73 | @Input() videoChapters: VideoChapter[] = [] | ||
74 | |||
71 | @Input() hideWaitTranscoding = false | 75 | @Input() hideWaitTranscoding = false |
72 | @Input() updateVideoFileEnabled = false | 76 | @Input() updateVideoFileEnabled = false |
73 | 77 | ||
@@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
150 | licence: this.serverConfig.defaults.publish.licence, | 154 | licence: this.serverConfig.defaults.publish.licence, |
151 | tags: [] | 155 | tags: [] |
152 | } | 156 | } |
153 | const obj: { [ id: string ]: BuildFormValidator } = { | 157 | const obj: BuildFormArgument = { |
154 | name: VIDEO_NAME_VALIDATOR, | 158 | name: VIDEO_NAME_VALIDATOR, |
155 | privacy: VIDEO_PRIVACY_VALIDATOR, | 159 | privacy: VIDEO_PRIVACY_VALIDATOR, |
156 | videoPassword: VIDEO_PASSWORD_VALIDATOR, | 160 | videoPassword: VIDEO_PASSWORD_VALIDATOR, |
@@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
183 | defaultValues | 187 | defaultValues |
184 | ) | 188 | ) |
185 | 189 | ||
186 | this.form.addControl('captions', new FormArray([ | 190 | this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS)) |
187 | new FormGroup({ | 191 | this.addNewChapterControl() |
188 | language: new FormControl(), | 192 | |
189 | captionfile: new FormControl() | 193 | this.form.get('chapters').valueChanges.subscribe((chapters: { title: string, timecode: string }[]) => { |
190 | }) | 194 | const lastChapter = chapters[chapters.length - 1] |
191 | ])) | 195 | |
196 | if (lastChapter.title || lastChapter.timecode) { | ||
197 | this.addNewChapterControl() | ||
198 | } | ||
199 | }) | ||
192 | 200 | ||
193 | this.trackChannelChange() | 201 | this.trackChannelChange() |
194 | this.trackPrivacyChange() | 202 | this.trackPrivacyChange() |
@@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
426 | this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup)) | 434 | this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup)) |
427 | } | 435 | } |
428 | 436 | ||
437 | // --------------------------------------------------------------------------- | ||
438 | |||
439 | addNewChapterControl () { | ||
440 | const chaptersFormArray = this.getChaptersFormArray() | ||
441 | const controls = chaptersFormArray.controls | ||
442 | |||
443 | if (controls.length !== 0) { | ||
444 | const lastControl = chaptersFormArray.controls[controls.length - 1] | ||
445 | lastControl.get('title').addValidators(Validators.required) | ||
446 | } | ||
447 | |||
448 | this.formValidatorService.addControlInFormArray({ | ||
449 | controlName: 'chapters', | ||
450 | formArray: chaptersFormArray, | ||
451 | formErrors: this.formErrors, | ||
452 | validationMessages: this.validationMessages, | ||
453 | formToBuild: { | ||
454 | timecode: null, | ||
455 | title: VIDEO_CHAPTER_TITLE_VALIDATOR | ||
456 | }, | ||
457 | defaultValues: { | ||
458 | timecode: 0 | ||
459 | } | ||
460 | }) | ||
461 | } | ||
462 | |||
463 | getChaptersFormArray () { | ||
464 | return this.form.controls['chapters'] as FormArray | ||
465 | } | ||
466 | |||
467 | deleteChapterControl (index: number) { | ||
468 | this.formValidatorService.removeControlFromFormArray({ | ||
469 | controlName: 'chapters', | ||
470 | formArray: this.getChaptersFormArray(), | ||
471 | formErrors: this.formErrors, | ||
472 | validationMessages: this.validationMessages, | ||
473 | index | ||
474 | }) | ||
475 | } | ||
476 | |||
477 | isLastChapterControl (index: number) { | ||
478 | return this.getChaptersFormArray().length - 1 === index | ||
479 | } | ||
480 | |||
481 | patchChapters (chaptersEdit: VideoChaptersEdit) { | ||
482 | const totalChapters = chaptersEdit.getChaptersForUpdate().length | ||
483 | const totalControls = this.getChaptersFormArray().length | ||
484 | |||
485 | // Add missing controls. We use <= because we need the "empty control" to add another chapter | ||
486 | for (let i = 0; i <= totalChapters - totalControls; i++) { | ||
487 | this.addNewChapterControl() | ||
488 | } | ||
489 | |||
490 | this.form.patchValue(chaptersEdit.toFormPatch()) | ||
491 | } | ||
492 | |||
493 | getChapterArrayErrors () { | ||
494 | if (!this.getChaptersFormArray().errors) return '' | ||
495 | |||
496 | return Object.values(this.getChaptersFormArray().errors).join('. ') | ||
497 | } | ||
498 | |||
499 | // --------------------------------------------------------------------------- | ||
500 | |||
429 | private trackPrivacyChange () { | 501 | private trackPrivacyChange () { |
430 | // We will update the schedule input and the wait transcoding checkbox validators | 502 | // We will update the schedule input and the wait transcoding checkbox validators |
431 | this.form.controls['privacy'] | 503 | this.form.controls['privacy'] |
@@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
469 | } else { | 541 | } else { |
470 | videoPasswordControl.clearValidators() | 542 | videoPasswordControl.clearValidators() |
471 | } | 543 | } |
472 | videoPasswordControl.updateValueAndValidity() | ||
473 | 544 | ||
545 | videoPasswordControl.updateValueAndValidity() | ||
474 | } | 546 | } |
475 | ) | 547 | ) |
476 | } | 548 | } |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts index f7a570ed3..69d12b85f 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts | |||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router' | |||
4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' |
5 | import { scrollToTop } from '@app/helpers' | 5 | import { scrollToTop } from '@app/helpers' |
6 | import { FormReactiveService } from '@app/shared/shared-forms' | 6 | import { FormReactiveService } from '@app/shared/shared-forms' |
7 | import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 7 | import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' |
8 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
10 | import { logger } from '@root-helpers/logger' | 10 | import { logger } from '@root-helpers/logger' |
@@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView | |||
54 | protected serverService: ServerService, | 54 | protected serverService: ServerService, |
55 | protected videoService: VideoService, | 55 | protected videoService: VideoService, |
56 | protected videoCaptionService: VideoCaptionService, | 56 | protected videoCaptionService: VideoCaptionService, |
57 | protected videoChapterService: VideoChapterService, | ||
57 | private liveVideoService: LiveVideoService, | 58 | private liveVideoService: LiveVideoService, |
58 | private router: Router, | 59 | private router: Router, |
59 | private hooks: HooksService | 60 | private hooks: HooksService |
@@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView | |||
137 | video.uuid = this.videoUUID | 138 | video.uuid = this.videoUUID |
138 | video.shortUUID = this.videoShortUUID | 139 | video.shortUUID = this.videoShortUUID |
139 | 140 | ||
141 | this.chaptersEdit.patch(this.form.value) | ||
142 | |||
140 | const saveReplay = this.form.value.saveReplay | 143 | const saveReplay = this.form.value.saveReplay |
141 | const replaySettings = saveReplay | 144 | const replaySettings = saveReplay |
142 | ? { privacy: this.form.value.replayPrivacy } | 145 | ? { privacy: this.form.value.replayPrivacy } |
@@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView | |||
151 | 154 | ||
152 | // Update the video | 155 | // Update the video |
153 | forkJoin([ | 156 | forkJoin([ |
154 | this.updateVideoAndCaptions(video), | 157 | this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }), |
155 | 158 | ||
156 | this.liveVideoService.updateLive(this.videoId, liveVideoUpdate) | 159 | this.liveVideoService.updateLive(this.videoId, liveVideoUpdate) |
157 | ]).subscribe({ | 160 | ]).subscribe({ |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts index 97517e1c7..50eb14c6e 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts | |||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router' | |||
4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' |
5 | import { scrollToTop } from '@app/helpers' | 5 | import { scrollToTop } from '@app/helpers' |
6 | import { FormReactiveService } from '@app/shared/shared-forms' | 6 | import { FormReactiveService } from '@app/shared/shared-forms' |
7 | import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' | 7 | import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' |
8 | import { LoadingBarService } from '@ngx-loading-bar/core' | 8 | import { LoadingBarService } from '@ngx-loading-bar/core' |
9 | import { logger } from '@root-helpers/logger' | 9 | import { logger } from '@root-helpers/logger' |
10 | import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' | 10 | import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' |
@@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af | |||
42 | protected serverService: ServerService, | 42 | protected serverService: ServerService, |
43 | protected videoService: VideoService, | 43 | protected videoService: VideoService, |
44 | protected videoCaptionService: VideoCaptionService, | 44 | protected videoCaptionService: VideoCaptionService, |
45 | protected videoChapterService: VideoChapterService, | ||
45 | private router: Router, | 46 | private router: Router, |
46 | private videoImportService: VideoImportService, | 47 | private videoImportService: VideoImportService, |
47 | private hooks: HooksService | 48 | private hooks: HooksService |
@@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af | |||
124 | if (!await this.isFormValid()) return | 125 | if (!await this.isFormValid()) return |
125 | 126 | ||
126 | this.video.patch(this.form.value) | 127 | this.video.patch(this.form.value) |
128 | this.chaptersEdit.patch(this.form.value) | ||
127 | 129 | ||
128 | this.isUpdatingVideo = true | 130 | this.isUpdatingVideo = true |
129 | 131 | ||
130 | // Update the video | 132 | // Update the video |
131 | this.updateVideoAndCaptions(this.video) | 133 | this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit }) |
132 | .subscribe({ | 134 | .subscribe({ |
133 | next: () => { | 135 | next: () => { |
134 | this.isUpdatingVideo = false | 136 | this.isUpdatingVideo = false |
135 | this.notifier.success($localize`Video to import updated.`) | 137 | this.notifier.success($localize`Video to import updated.`) |
136 | 138 | ||
137 | this.router.navigate([ '/my-library', 'video-imports' ]) | 139 | this.router.navigate([ '/my-library', 'video-imports' ]) |
138 | }, | 140 | }, |
139 | 141 | ||
140 | error: err => { | 142 | error: err => { |
141 | this.error = err.message | 143 | this.error = err.message |
142 | scrollToTop() | 144 | scrollToTop() |
143 | logger.error(err) | 145 | logger.error(err) |
144 | } | 146 | } |
145 | }) | 147 | }) |
146 | } | 148 | } |
147 | } | 149 | } |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html index a80d31aaf..30eeca704 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html | |||
@@ -56,6 +56,7 @@ | |||
56 | <!-- Hidden because we want to load the component --> | 56 | <!-- Hidden because we want to load the component --> |
57 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> | 57 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> |
58 | <my-video-edit | 58 | <my-video-edit |
59 | #videoEdit | ||
59 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true" | 60 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [forbidScheduledPublication]="true" |
60 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | 61 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" |
61 | type="import-url" | 62 | type="import-url" |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts index 634bd9914..4dc04b83e 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts | |||
@@ -1,16 +1,17 @@ | |||
1 | import { forkJoin } from 'rxjs' | 1 | import { forkJoin } from 'rxjs' |
2 | import { map, switchMap } from 'rxjs/operators' | 2 | import { map, switchMap } from 'rxjs/operators' |
3 | import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular/core' | 3 | import { AfterViewInit, Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { Router } from '@angular/router' | 4 | import { Router } from '@angular/router' |
5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' | 5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' |
6 | import { scrollToTop } from '@app/helpers' | 6 | import { scrollToTop } from '@app/helpers' |
7 | import { FormReactiveService } from '@app/shared/shared-forms' | 7 | import { FormReactiveService } from '@app/shared/shared-forms' |
8 | import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' | 8 | import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' |
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
10 | import { logger } from '@root-helpers/logger' | 10 | import { logger } from '@root-helpers/logger' |
11 | import { VideoUpdate } from '@peertube/peertube-models' | 11 | import { VideoUpdate } from '@peertube/peertube-models' |
12 | import { hydrateFormFromVideo } from '../shared/video-edit-utils' | 12 | import { hydrateFormFromVideo } from '../shared/video-edit-utils' |
13 | import { VideoSend } from './video-send' | 13 | import { VideoSend } from './video-send' |
14 | import { VideoEditComponent } from '../shared/video-edit.component' | ||
14 | 15 | ||
15 | @Component({ | 16 | @Component({ |
16 | selector: 'my-video-import-url', | 17 | selector: 'my-video-import-url', |
@@ -21,6 +22,8 @@ import { VideoSend } from './video-send' | |||
21 | ] | 22 | ] |
22 | }) | 23 | }) |
23 | export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate { | 24 | export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate { |
25 | @ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent | ||
26 | |||
24 | @Output() firstStepDone = new EventEmitter<string>() | 27 | @Output() firstStepDone = new EventEmitter<string>() |
25 | @Output() firstStepError = new EventEmitter<void>() | 28 | @Output() firstStepError = new EventEmitter<void>() |
26 | 29 | ||
@@ -41,6 +44,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV | |||
41 | protected serverService: ServerService, | 44 | protected serverService: ServerService, |
42 | protected videoService: VideoService, | 45 | protected videoService: VideoService, |
43 | protected videoCaptionService: VideoCaptionService, | 46 | protected videoCaptionService: VideoCaptionService, |
47 | protected videoChapterService: VideoChapterService, | ||
44 | private router: Router, | 48 | private router: Router, |
45 | private videoImportService: VideoImportService, | 49 | private videoImportService: VideoImportService, |
46 | private hooks: HooksService | 50 | private hooks: HooksService |
@@ -85,12 +89,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV | |||
85 | switchMap(previous => { | 89 | switchMap(previous => { |
86 | return forkJoin([ | 90 | return forkJoin([ |
87 | this.videoCaptionService.listCaptions(previous.video.uuid), | 91 | this.videoCaptionService.listCaptions(previous.video.uuid), |
92 | this.videoChapterService.getChapters({ videoId: previous.video.uuid }), | ||
88 | this.videoService.getVideo({ videoId: previous.video.uuid }) | 93 | this.videoService.getVideo({ videoId: previous.video.uuid }) |
89 | ]).pipe(map(([ videoCaptionsResult, video ]) => ({ videoCaptions: videoCaptionsResult.data, video }))) | 94 | ]).pipe(map(([ videoCaptionsResult, { chapters }, video ]) => ({ videoCaptions: videoCaptionsResult.data, chapters, video }))) |
90 | }) | 95 | }) |
91 | ) | 96 | ) |
92 | .subscribe({ | 97 | .subscribe({ |
93 | next: ({ video, videoCaptions }) => { | 98 | next: ({ video, videoCaptions, chapters }) => { |
94 | this.loadingBar.useRef().complete() | 99 | this.loadingBar.useRef().complete() |
95 | this.firstStepDone.emit(video.name) | 100 | this.firstStepDone.emit(video.name) |
96 | this.isImportingVideo = false | 101 | this.isImportingVideo = false |
@@ -99,9 +104,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV | |||
99 | this.video = new VideoEdit(video) | 104 | this.video = new VideoEdit(video) |
100 | this.video.patch({ privacy: this.firstStepPrivacyId }) | 105 | this.video.patch({ privacy: this.firstStepPrivacyId }) |
101 | 106 | ||
107 | this.chaptersEdit.loadFromAPI(chapters) | ||
108 | |||
102 | this.videoCaptions = videoCaptions | 109 | this.videoCaptions = videoCaptions |
103 | 110 | ||
104 | hydrateFormFromVideo(this.form, this.video, true) | 111 | hydrateFormFromVideo(this.form, this.video, true) |
112 | setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit)) | ||
105 | }, | 113 | }, |
106 | 114 | ||
107 | error: err => { | 115 | error: err => { |
@@ -117,11 +125,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV | |||
117 | if (!await this.isFormValid()) return | 125 | if (!await this.isFormValid()) return |
118 | 126 | ||
119 | this.video.patch(this.form.value) | 127 | this.video.patch(this.form.value) |
128 | this.chaptersEdit.patch(this.form.value) | ||
120 | 129 | ||
121 | this.isUpdatingVideo = true | 130 | this.isUpdatingVideo = true |
122 | 131 | ||
123 | // Update the video | 132 | // Update the video |
124 | this.updateVideoAndCaptions(this.video) | 133 | this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit }) |
125 | .subscribe({ | 134 | .subscribe({ |
126 | next: () => { | 135 | next: () => { |
127 | this.isUpdatingVideo = false | 136 | this.isUpdatingVideo = false |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts index 56dcfa0e6..2c38e11a3 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts | |||
@@ -4,9 +4,17 @@ import { Directive, EventEmitter, OnInit } from '@angular/core' | |||
4 | import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' |
5 | import { listUserChannelsForSelect } from '@app/helpers' | 5 | import { listUserChannelsForSelect } from '@app/helpers' |
6 | import { FormReactive } from '@app/shared/shared-forms' | 6 | import { FormReactive } from '@app/shared/shared-forms' |
7 | import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 7 | import { |
8 | VideoCaptionEdit, | ||
9 | VideoCaptionService, | ||
10 | VideoChapterService, | ||
11 | VideoChaptersEdit, | ||
12 | VideoEdit, | ||
13 | VideoService | ||
14 | } from '@app/shared/shared-main' | ||
8 | import { LoadingBarService } from '@ngx-loading-bar/core' | 15 | import { LoadingBarService } from '@ngx-loading-bar/core' |
9 | import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' | 16 | import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' |
17 | import { of } from 'rxjs' | ||
10 | 18 | ||
11 | @Directive() | 19 | @Directive() |
12 | // eslint-disable-next-line @angular-eslint/directive-class-suffix | 20 | // eslint-disable-next-line @angular-eslint/directive-class-suffix |
@@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit { | |||
14 | userVideoChannels: SelectChannelItem[] = [] | 22 | userVideoChannels: SelectChannelItem[] = [] |
15 | videoPrivacies: VideoConstant<VideoPrivacyType>[] = [] | 23 | videoPrivacies: VideoConstant<VideoPrivacyType>[] = [] |
16 | videoCaptions: VideoCaptionEdit[] = [] | 24 | videoCaptions: VideoCaptionEdit[] = [] |
25 | chaptersEdit = new VideoChaptersEdit() | ||
17 | 26 | ||
18 | firstStepPrivacyId: VideoPrivacyType | 27 | firstStepPrivacyId: VideoPrivacyType |
19 | firstStepChannelId: number | 28 | firstStepChannelId: number |
@@ -28,6 +37,7 @@ export abstract class VideoSend extends FormReactive implements OnInit { | |||
28 | protected serverService: ServerService | 37 | protected serverService: ServerService |
29 | protected videoService: VideoService | 38 | protected videoService: VideoService |
30 | protected videoCaptionService: VideoCaptionService | 39 | protected videoCaptionService: VideoCaptionService |
40 | protected videoChapterService: VideoChapterService | ||
31 | 41 | ||
32 | protected serverConfig: HTMLServerConfig | 42 | protected serverConfig: HTMLServerConfig |
33 | 43 | ||
@@ -60,13 +70,23 @@ export abstract class VideoSend extends FormReactive implements OnInit { | |||
60 | }) | 70 | }) |
61 | } | 71 | } |
62 | 72 | ||
63 | protected updateVideoAndCaptions (video: VideoEdit) { | 73 | protected updateVideoAndCaptionsAndChapters (options: { |
74 | video: VideoEdit | ||
75 | captions: VideoCaptionEdit[] | ||
76 | chapters?: VideoChaptersEdit | ||
77 | }) { | ||
78 | const { video, captions, chapters } = options | ||
79 | |||
64 | this.loadingBar.useRef().start() | 80 | this.loadingBar.useRef().start() |
65 | 81 | ||
66 | return this.videoService.updateVideo(video) | 82 | return this.videoService.updateVideo(video) |
67 | .pipe( | 83 | .pipe( |
68 | // Then update captions | 84 | switchMap(() => this.videoCaptionService.updateCaptions(video.uuid, captions)), |
69 | switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)), | 85 | switchMap(() => { |
86 | return chapters | ||
87 | ? this.videoChapterService.updateChapters(video.uuid, chapters) | ||
88 | : of(true) | ||
89 | }), | ||
70 | tap(() => this.loadingBar.useRef().complete()), | 90 | tap(() => this.loadingBar.useRef().complete()), |
71 | catchError(err => { | 91 | catchError(err => { |
72 | this.loadingBar.useRef().complete() | 92 | this.loadingBar.useRef().complete() |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts index cbf43ee5f..cc0dcc1ae 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts | |||
@@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router' | |||
7 | import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' | 7 | import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' |
8 | import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' | 8 | import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' |
9 | import { FormReactiveService } from '@app/shared/shared-forms' | 9 | import { FormReactiveService } from '@app/shared/shared-forms' |
10 | import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 10 | import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' |
11 | import { LoadingBarService } from '@ngx-loading-bar/core' | 11 | import { LoadingBarService } from '@ngx-loading-bar/core' |
12 | import { logger } from '@root-helpers/logger' | 12 | import { logger } from '@root-helpers/logger' |
13 | import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' | 13 | import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' |
@@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
63 | protected serverService: ServerService, | 63 | protected serverService: ServerService, |
64 | protected videoService: VideoService, | 64 | protected videoService: VideoService, |
65 | protected videoCaptionService: VideoCaptionService, | 65 | protected videoCaptionService: VideoCaptionService, |
66 | protected videoChapterService: VideoChapterService, | ||
66 | private userService: UserService, | 67 | private userService: UserService, |
67 | private router: Router, | 68 | private router: Router, |
68 | private hooks: HooksService, | 69 | private hooks: HooksService, |
@@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
241 | video.uuid = this.videoUploadedIds.uuid | 242 | video.uuid = this.videoUploadedIds.uuid |
242 | video.shortUUID = this.videoUploadedIds.shortUUID | 243 | video.shortUUID = this.videoUploadedIds.shortUUID |
243 | 244 | ||
245 | this.chaptersEdit.patch(this.form.value) | ||
246 | |||
244 | this.isUpdatingVideo = true | 247 | this.isUpdatingVideo = true |
245 | 248 | ||
246 | this.updateVideoAndCaptions(video) | 249 | this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit }) |
247 | .subscribe({ | 250 | .subscribe({ |
248 | next: () => { | 251 | next: () => { |
249 | this.isUpdatingVideo = false | 252 | this.isUpdatingVideo = false |
diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html index 9a99c0c3d..2f667658c 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.html +++ b/client/src/app/+videos/+video-edit/video-update.component.html | |||
@@ -13,6 +13,7 @@ | |||
13 | <form novalidate [formGroup]="form"> | 13 | <form novalidate [formGroup]="form"> |
14 | 14 | ||
15 | <my-video-edit | 15 | <my-video-edit |
16 | #videoEdit | ||
16 | [form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication" | 17 | [form]="form" [formErrors]="formErrors" [forbidScheduledPublication]="forbidScheduledPublication" |
17 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | 18 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" |
18 | [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()" | 19 | [videoCaptions]="videoCaptions" [hideWaitTranscoding]="isWaitTranscodingHidden()" |
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 ea2f76d71..82f45f73d 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts | |||
@@ -4,18 +4,28 @@ import { of, Subject, Subscription } from 'rxjs' | |||
4 | import { catchError, map, switchMap } from 'rxjs/operators' | 4 | import { catchError, map, switchMap } from 'rxjs/operators' |
5 | import { SelectChannelItem } from 'src/types/select-options-item.model' | 5 | import { SelectChannelItem } from 'src/types/select-options-item.model' |
6 | import { HttpErrorResponse } from '@angular/common/http' | 6 | import { HttpErrorResponse } from '@angular/common/http' |
7 | import { Component, HostListener, OnDestroy, OnInit } from '@angular/core' | 7 | import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core' |
8 | import { ActivatedRoute, Router } from '@angular/router' | 8 | import { ActivatedRoute, Router } from '@angular/router' |
9 | import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' | 9 | import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' |
10 | import { genericUploadErrorHandler } from '@app/helpers' | 10 | import { genericUploadErrorHandler } from '@app/helpers' |
11 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | 11 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' |
12 | import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' | 12 | import { |
13 | Video, | ||
14 | VideoCaptionEdit, | ||
15 | VideoCaptionService, | ||
16 | VideoChapterService, | ||
17 | VideoChaptersEdit, | ||
18 | VideoDetails, | ||
19 | VideoEdit, | ||
20 | VideoService | ||
21 | } from '@app/shared/shared-main' | ||
13 | import { LiveVideoService } from '@app/shared/shared-video-live' | 22 | import { LiveVideoService } from '@app/shared/shared-video-live' |
14 | import { LoadingBarService } from '@ngx-loading-bar/core' | 23 | import { LoadingBarService } from '@ngx-loading-bar/core' |
15 | import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils' | 24 | import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils' |
16 | import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models' | 25 | import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models' |
17 | import { hydrateFormFromVideo } from './shared/video-edit-utils' | 26 | import { hydrateFormFromVideo } from './shared/video-edit-utils' |
18 | import { VideoUploadService } from './shared/video-upload.service' | 27 | import { VideoUploadService } from './shared/video-upload.service' |
28 | import { VideoEditComponent } from './shared/video-edit.component' | ||
19 | 29 | ||
20 | const debugLogger = debug('peertube:video-update') | 30 | const debugLogger = debug('peertube:video-update') |
21 | 31 | ||
@@ -25,6 +35,8 @@ const debugLogger = debug('peertube:video-update') | |||
25 | templateUrl: './video-update.component.html' | 35 | templateUrl: './video-update.component.html' |
26 | }) | 36 | }) |
27 | export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { | 37 | export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { |
38 | @ViewChild('videoEdit', { static: false }) videoEditComponent: VideoEditComponent | ||
39 | |||
28 | videoEdit: VideoEdit | 40 | videoEdit: VideoEdit |
29 | videoDetails: VideoDetails | 41 | videoDetails: VideoDetails |
30 | videoSource: VideoSource | 42 | videoSource: VideoSource |
@@ -50,6 +62,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest | |||
50 | private uploadServiceSubscription: Subscription | 62 | private uploadServiceSubscription: Subscription |
51 | private updateSubcription: Subscription | 63 | private updateSubcription: Subscription |
52 | 64 | ||
65 | private chaptersEdit = new VideoChaptersEdit() | ||
66 | |||
53 | constructor ( | 67 | constructor ( |
54 | protected formReactiveService: FormReactiveService, | 68 | protected formReactiveService: FormReactiveService, |
55 | private route: ActivatedRoute, | 69 | private route: ActivatedRoute, |
@@ -58,6 +72,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest | |||
58 | private videoService: VideoService, | 72 | private videoService: VideoService, |
59 | private loadingBar: LoadingBarService, | 73 | private loadingBar: LoadingBarService, |
60 | private videoCaptionService: VideoCaptionService, | 74 | private videoCaptionService: VideoCaptionService, |
75 | private videoChapterService: VideoChapterService, | ||
61 | private server: ServerService, | 76 | private server: ServerService, |
62 | private liveVideoService: LiveVideoService, | 77 | private liveVideoService: LiveVideoService, |
63 | private videoUploadService: VideoUploadService, | 78 | private videoUploadService: VideoUploadService, |
@@ -84,10 +99,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest | |||
84 | .subscribe(state => this.onUploadVideoOngoing(state)) | 99 | .subscribe(state => this.onUploadVideoOngoing(state)) |
85 | 100 | ||
86 | const { videoData } = this.route.snapshot.data | 101 | const { videoData } = this.route.snapshot.data |
87 | const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData | 102 | const { video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword } = videoData |
88 | 103 | ||
89 | this.videoDetails = video | 104 | this.videoDetails = video |
90 | this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) | 105 | this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) |
106 | this.chaptersEdit.loadFromAPI(videoChapters) | ||
91 | 107 | ||
92 | this.userVideoChannels = videoChannels | 108 | this.userVideoChannels = videoChannels |
93 | this.videoCaptions = videoCaptions | 109 | this.videoCaptions = videoCaptions |
@@ -106,6 +122,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest | |||
106 | onFormBuilt () { | 122 | onFormBuilt () { |
107 | hydrateFormFromVideo(this.form, this.videoEdit, true) | 123 | hydrateFormFromVideo(this.form, this.videoEdit, true) |
108 | 124 | ||
125 | setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit)) | ||
126 | |||
109 | if (this.liveVideo) { | 127 | if (this.liveVideo) { |
110 | this.form.patchValue({ | 128 | this.form.patchValue({ |
111 | saveReplay: this.liveVideo.saveReplay, | 129 | saveReplay: this.liveVideo.saveReplay, |
@@ -172,6 +190,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest | |||
172 | if (!await this.checkAndConfirmVideoFileReplacement()) return | 190 | if (!await this.checkAndConfirmVideoFileReplacement()) return |
173 | 191 | ||
174 | this.videoEdit.patch(this.form.value) | 192 | this.videoEdit.patch(this.form.value) |
193 | this.chaptersEdit.patch(this.form.value) | ||
175 | 194 | ||
176 | this.abortUpdateIfNeeded() | 195 | this.abortUpdateIfNeeded() |
177 | 196 | ||
@@ -180,10 +199,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest | |||
180 | 199 | ||
181 | this.updateSubcription = this.videoReplacementUploadedSubject.pipe( | 200 | this.updateSubcription = this.videoReplacementUploadedSubject.pipe( |
182 | switchMap(() => this.videoService.updateVideo(this.videoEdit)), | 201 | switchMap(() => this.videoService.updateVideo(this.videoEdit)), |
202 | switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.uuid, this.videoCaptions)), | ||
203 | switchMap(() => { | ||
204 | if (this.liveVideo) return of(true) | ||
183 | 205 | ||
184 | // Then update captions | 206 | return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit) |
185 | switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), | 207 | }), |
186 | |||
187 | switchMap(() => { | 208 | switchMap(() => { |
188 | if (!this.liveVideo) return of(undefined) | 209 | if (!this.liveVideo) return of(undefined) |
189 | 210 | ||
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 d114bfb2d..0293f3c71 100644 --- a/client/src/app/+videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts | |||
@@ -4,7 +4,7 @@ import { Injectable } from '@angular/core' | |||
4 | import { ActivatedRouteSnapshot } from '@angular/router' | 4 | import { ActivatedRouteSnapshot } from '@angular/router' |
5 | import { AuthService } from '@app/core' | 5 | import { AuthService } from '@app/core' |
6 | import { listUserChannelsForSelect } from '@app/helpers' | 6 | import { listUserChannelsForSelect } from '@app/helpers' |
7 | import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' | 7 | import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' |
8 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
9 | import { VideoPrivacy } from '@peertube/peertube-models' | 9 | import { VideoPrivacy } from '@peertube/peertube-models' |
10 | 10 | ||
@@ -15,6 +15,7 @@ export class VideoUpdateResolver { | |||
15 | private liveVideoService: LiveVideoService, | 15 | private liveVideoService: LiveVideoService, |
16 | private authService: AuthService, | 16 | private authService: AuthService, |
17 | private videoCaptionService: VideoCaptionService, | 17 | private videoCaptionService: VideoCaptionService, |
18 | private videoChapterService: VideoChapterService, | ||
18 | private videoPasswordService: VideoPasswordService | 19 | private videoPasswordService: VideoPasswordService |
19 | ) { | 20 | ) { |
20 | } | 21 | } |
@@ -25,8 +26,8 @@ export class VideoUpdateResolver { | |||
25 | return this.videoService.getVideo({ videoId: uuid }) | 26 | return this.videoService.getVideo({ videoId: uuid }) |
26 | .pipe( | 27 | .pipe( |
27 | switchMap(video => forkJoin(this.buildVideoObservables(video))), | 28 | switchMap(video => forkJoin(this.buildVideoObservables(video))), |
28 | map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) => | 29 | map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) => |
29 | ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword })) | 30 | ({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword })) |
30 | ) | 31 | ) |
31 | } | 32 | } |
32 | 33 | ||
@@ -46,6 +47,12 @@ export class VideoUpdateResolver { | |||
46 | map(result => result.data) | 47 | map(result => result.data) |
47 | ), | 48 | ), |
48 | 49 | ||
50 | this.videoChapterService | ||
51 | .getChapters({ videoId: video.uuid }) | ||
52 | .pipe( | ||
53 | map(({ chapters }) => chapters) | ||
54 | ), | ||
55 | |||
49 | video.isLive | 56 | video.isLive |
50 | ? this.liveVideoService.getVideoLive(video.id) | 57 | ? this.liveVideoService.getVideoLive(video.id) |
51 | : of(undefined), | 58 | : of(undefined), |
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 febb3c828..39c9c7986 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts | |||
@@ -18,7 +18,7 @@ import { | |||
18 | } from '@app/core' | 18 | } from '@app/core' |
19 | import { HooksService } from '@app/core/plugins/hooks.service' | 19 | import { HooksService } from '@app/core/plugins/hooks.service' |
20 | import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' | 20 | import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' |
21 | import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' | 21 | import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' |
22 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' | 22 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' |
23 | import { LiveVideoService } from '@app/shared/shared-video-live' | 23 | import { LiveVideoService } from '@app/shared/shared-video-live' |
24 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | 24 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' |
@@ -31,6 +31,7 @@ import { | |||
31 | ServerErrorCode, | 31 | ServerErrorCode, |
32 | Storyboard, | 32 | Storyboard, |
33 | VideoCaption, | 33 | VideoCaption, |
34 | VideoChapter, | ||
34 | VideoPrivacy, | 35 | VideoPrivacy, |
35 | VideoState, | 36 | VideoState, |
36 | VideoStateType | 37 | VideoStateType |
@@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
83 | 84 | ||
84 | video: VideoDetails = null | 85 | video: VideoDetails = null |
85 | videoCaptions: VideoCaption[] = [] | 86 | videoCaptions: VideoCaption[] = [] |
87 | videoChapters: VideoChapter[] = [] | ||
86 | liveVideo: LiveVideo | 88 | liveVideo: LiveVideo |
87 | videoPassword: string | 89 | videoPassword: string |
88 | storyboards: Storyboard[] = [] | 90 | storyboards: Storyboard[] = [] |
@@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
125 | private notifier: Notifier, | 127 | private notifier: Notifier, |
126 | private zone: NgZone, | 128 | private zone: NgZone, |
127 | private videoCaptionService: VideoCaptionService, | 129 | private videoCaptionService: VideoCaptionService, |
130 | private videoChapterService: VideoChapterService, | ||
128 | private hotkeysService: HotkeysService, | 131 | private hotkeysService: HotkeysService, |
129 | private hooks: HooksService, | 132 | private hooks: HooksService, |
130 | private pluginService: PluginService, | 133 | private pluginService: PluginService, |
@@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
306 | forkJoin([ | 309 | forkJoin([ |
307 | videoAndLiveObs, | 310 | videoAndLiveObs, |
308 | this.videoCaptionService.listCaptions(videoId, videoPassword), | 311 | this.videoCaptionService.listCaptions(videoId, videoPassword), |
312 | this.videoChapterService.getChapters({ videoId, videoPassword }), | ||
309 | this.videoService.getStoryboards(videoId, videoPassword), | 313 | this.videoService.getStoryboards(videoId, videoPassword), |
310 | this.userService.getAnonymousOrLoggedUser() | 314 | this.userService.getAnonymousOrLoggedUser() |
311 | ]).subscribe({ | 315 | ]).subscribe({ |
312 | next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { | 316 | next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => { |
313 | this.onVideoFetched({ | 317 | this.onVideoFetched({ |
314 | video, | 318 | video, |
315 | live, | 319 | live, |
316 | videoCaptions: captionsResult.data, | 320 | videoCaptions: captionsResult.data, |
321 | videoChapters: chaptersResult.chapters, | ||
317 | storyboards, | 322 | storyboards, |
318 | videoFileToken, | 323 | videoFileToken, |
319 | videoPassword, | 324 | videoPassword, |
@@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
411 | video: VideoDetails | 416 | video: VideoDetails |
412 | live: LiveVideo | 417 | live: LiveVideo |
413 | videoCaptions: VideoCaption[] | 418 | videoCaptions: VideoCaption[] |
419 | videoChapters: VideoChapter[] | ||
414 | storyboards: Storyboard[] | 420 | storyboards: Storyboard[] |
415 | videoFileToken: string | 421 | videoFileToken: string |
416 | videoPassword: string | 422 | videoPassword: string |
@@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
422 | video, | 428 | video, |
423 | live, | 429 | live, |
424 | videoCaptions, | 430 | videoCaptions, |
431 | videoChapters, | ||
425 | storyboards, | 432 | storyboards, |
426 | videoFileToken, | 433 | videoFileToken, |
427 | videoPassword, | 434 | videoPassword, |
@@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
433 | 440 | ||
434 | this.video = video | 441 | this.video = video |
435 | this.videoCaptions = videoCaptions | 442 | this.videoCaptions = videoCaptions |
443 | this.videoChapters = videoChapters | ||
436 | this.liveVideo = live | 444 | this.liveVideo = live |
437 | this.videoFileToken = videoFileToken | 445 | this.videoFileToken = videoFileToken |
438 | this.videoPassword = videoPassword | 446 | this.videoPassword = videoPassword |
@@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
480 | const params = { | 488 | const params = { |
481 | video: this.video, | 489 | video: this.video, |
482 | videoCaptions: this.videoCaptions, | 490 | videoCaptions: this.videoCaptions, |
491 | videoChapters: this.videoChapters, | ||
483 | storyboards: this.storyboards, | 492 | storyboards: this.storyboards, |
484 | liveVideo: this.liveVideo, | 493 | liveVideo: this.liveVideo, |
485 | videoFileToken: this.videoFileToken, | 494 | videoFileToken: this.videoFileToken, |
@@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
636 | video: VideoDetails | 645 | video: VideoDetails |
637 | liveVideo: LiveVideo | 646 | liveVideo: LiveVideo |
638 | videoCaptions: VideoCaption[] | 647 | videoCaptions: VideoCaption[] |
648 | videoChapters: VideoChapter[] | ||
639 | storyboards: Storyboard[] | 649 | storyboards: Storyboard[] |
640 | 650 | ||
641 | videoFileToken: string | 651 | videoFileToken: string |
@@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
651 | video, | 661 | video, |
652 | liveVideo, | 662 | liveVideo, |
653 | videoCaptions, | 663 | videoCaptions, |
664 | videoChapters, | ||
654 | storyboards, | 665 | storyboards, |
655 | videoFileToken, | 666 | videoFileToken, |
656 | videoPassword, | 667 | videoPassword, |
@@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
750 | videoPassword: () => videoPassword, | 761 | videoPassword: () => videoPassword, |
751 | 762 | ||
752 | videoCaptions: playerCaptions, | 763 | videoCaptions: playerCaptions, |
764 | videoChapters, | ||
753 | storyboard, | 765 | storyboard, |
754 | 766 | ||
755 | videoShortUUID: video.shortUUID, | 767 | videoShortUUID: video.shortUUID, |
diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts index b69e31edf..ef1acd298 100644 --- a/client/src/app/helpers/utils/object.ts +++ b/client/src/app/helpers/utils/object.ts | |||
@@ -7,17 +7,6 @@ function removeElementFromArray <T> (arr: T[], elem: T) { | |||
7 | if (index !== -1) arr.splice(index, 1) | 7 | if (index !== -1) arr.splice(index, 1) |
8 | } | 8 | } |
9 | 9 | ||
10 | function sortBy (obj: any[], key1: string, key2?: string) { | ||
11 | return obj.sort((a, b) => { | ||
12 | const elem1 = key2 ? a[key1][key2] : a[key1] | ||
13 | const elem2 = key2 ? b[key1][key2] : b[key1] | ||
14 | |||
15 | if (elem1 < elem2) return -1 | ||
16 | if (elem1 === elem2) return 0 | ||
17 | return 1 | ||
18 | }) | ||
19 | } | ||
20 | |||
21 | function splitIntoArray (value: any) { | 10 | function splitIntoArray (value: any) { |
22 | if (!value) return undefined | 11 | if (!value) return undefined |
23 | if (Array.isArray(value)) return value | 12 | if (Array.isArray(value)) return value |
@@ -41,7 +30,6 @@ function toBoolean (value: any) { | |||
41 | } | 30 | } |
42 | 31 | ||
43 | export { | 32 | export { |
44 | sortBy, | ||
45 | immutableAssign, | 33 | immutableAssign, |
46 | removeElementFromArray, | 34 | removeElementFromArray, |
47 | splitIntoArray, | 35 | splitIntoArray, |
diff --git a/client/src/app/menu/language-chooser.component.ts b/client/src/app/menu/language-chooser.component.ts index 1ec5987c2..978a4af39 100644 --- a/client/src/app/menu/language-chooser.component.ts +++ b/client/src/app/menu/language-chooser.component.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' |
2 | import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers' | 2 | import { getDevLocale, isOnDevLocale } from '@app/helpers' |
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils' | 4 | import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils' |
5 | 5 | ||
6 | @Component({ | 6 | @Component({ |
7 | selector: 'my-language-chooser', | 7 | selector: 'my-language-chooser', |
diff --git a/client/src/app/shared/form-validators/video-chapter-validators.ts b/client/src/app/shared/form-validators/video-chapter-validators.ts new file mode 100644 index 000000000..cbbd9291e --- /dev/null +++ b/client/src/app/shared/form-validators/video-chapter-validators.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms' | ||
2 | import { BuildFormValidator } from './form-validator.model' | ||
3 | |||
4 | export const VIDEO_CHAPTER_TITLE_VALIDATOR: BuildFormValidator = { | ||
5 | VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically | ||
6 | MESSAGES: { | ||
7 | required: $localize`A chapter title is required.`, | ||
8 | minlength: $localize`A chapter title should be more than 2 characters long.`, | ||
9 | maxlength: $localize`A chapter title should be less than 100 characters long.` | ||
10 | } | ||
11 | } | ||
12 | |||
13 | export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = { | ||
14 | VALIDATORS: [ uniqueTimecodeValidator() ], | ||
15 | MESSAGES: {} | ||
16 | } | ||
17 | |||
18 | function uniqueTimecodeValidator (): ValidatorFn { | ||
19 | return (control: AbstractControl): ValidationErrors => { | ||
20 | const array = control.value as { timecode: number, title: string }[] | ||
21 | |||
22 | for (const chapter of array) { | ||
23 | if (!chapter.title) continue | ||
24 | |||
25 | if (array.filter(c => c.title && c.timecode === chapter.timecode).length > 1) { | ||
26 | return { uniqueTimecode: $localize`Multiple chapters have the same timecode ${chapter.timecode}` } | ||
27 | } | ||
28 | } | ||
29 | |||
30 | return null | ||
31 | } | ||
32 | } | ||
diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts index 090a76e43..a434c777f 100644 --- a/client/src/app/shared/form-validators/video-validators.ts +++ b/client/src/app/shared/form-validators/video-validators.ts | |||
@@ -70,14 +70,6 @@ export const VIDEO_DESCRIPTION_VALIDATOR: BuildFormValidator = { | |||
70 | } | 70 | } |
71 | } | 71 | } |
72 | 72 | ||
73 | export const VIDEO_TAG_VALIDATOR: BuildFormValidator = { | ||
74 | VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], | ||
75 | MESSAGES: { | ||
76 | minlength: $localize`A tag should be more than 2 characters long.`, | ||
77 | maxlength: $localize`A tag should be less than 30 characters long.` | ||
78 | } | ||
79 | } | ||
80 | |||
81 | export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { | 73 | export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { |
82 | VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ], | 74 | VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ], |
83 | MESSAGES: { | 75 | MESSAGES: { |
diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts index f1b7e0ef2..b960c310e 100644 --- a/client/src/app/shared/shared-forms/form-reactive.service.ts +++ b/client/src/app/shared/shared-forms/form-reactive.service.ts | |||
@@ -4,9 +4,9 @@ import { wait } from '@root-helpers/utils' | |||
4 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' | 4 | import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' |
5 | import { FormValidatorService } from './form-validator.service' | 5 | import { FormValidatorService } from './form-validator.service' |
6 | 6 | ||
7 | export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } | 7 | export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] } |
8 | export type FormReactiveValidationMessages = { | 8 | export type FormReactiveValidationMessages = { |
9 | [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages | 9 | [ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[] |
10 | } | 10 | } |
11 | 11 | ||
12 | @Injectable() | 12 | @Injectable() |
@@ -86,7 +86,7 @@ export class FormReactiveService { | |||
86 | 86 | ||
87 | if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue | 87 | if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue |
88 | 88 | ||
89 | const staticMessages = validationMessages[field] | 89 | const staticMessages = validationMessages[field] as FormReactiveValidationMessages |
90 | for (const key of Object.keys(control.errors)) { | 90 | for (const key of Object.keys(control.errors)) { |
91 | const formErrorValue = control.errors[key] | 91 | const formErrorValue = control.errors[key] |
92 | 92 | ||
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts index e7dedf52a..d810285bb 100644 --- a/client/src/app/shared/shared-forms/form-validator.service.ts +++ b/client/src/app/shared/shared-forms/form-validator.service.ts | |||
@@ -45,20 +45,20 @@ export class FormValidatorService { | |||
45 | form: FormGroup, | 45 | form: FormGroup, |
46 | formErrors: FormReactiveErrors, | 46 | formErrors: FormReactiveErrors, |
47 | validationMessages: FormReactiveValidationMessages, | 47 | validationMessages: FormReactiveValidationMessages, |
48 | obj: BuildFormArgument, | 48 | formToBuild: BuildFormArgument, |
49 | defaultValues: BuildFormDefaultValues = {} | 49 | defaultValues: BuildFormDefaultValues = {} |
50 | ) { | 50 | ) { |
51 | for (const name of objectKeysTyped(obj)) { | 51 | for (const name of objectKeysTyped(formToBuild)) { |
52 | formErrors[name] = '' | 52 | formErrors[name] = '' |
53 | 53 | ||
54 | const field = obj[name] | 54 | const field = formToBuild[name] |
55 | if (this.isRecursiveField(field)) { | 55 | if (this.isRecursiveField(field)) { |
56 | this.updateFormGroup( | 56 | this.updateFormGroup( |
57 | // FIXME: typings | 57 | // FIXME: typings |
58 | (form as any)[name], | 58 | (form as any)[name], |
59 | formErrors[name] as FormReactiveErrors, | 59 | formErrors[name] as FormReactiveErrors, |
60 | validationMessages[name] as FormReactiveValidationMessages, | 60 | validationMessages[name] as FormReactiveValidationMessages, |
61 | obj[name] as BuildFormArgument, | 61 | formToBuild[name] as BuildFormArgument, |
62 | defaultValues[name] as BuildFormDefaultValues | 62 | defaultValues[name] as BuildFormDefaultValues |
63 | ) | 63 | ) |
64 | continue | 64 | continue |
@@ -66,7 +66,7 @@ export class FormValidatorService { | |||
66 | 66 | ||
67 | if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } | 67 | if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } |
68 | 68 | ||
69 | const defaultValue = defaultValues[name] || '' | 69 | const defaultValue = defaultValues[name] ?? '' |
70 | 70 | ||
71 | form.addControl( | 71 | form.addControl( |
72 | name + '', | 72 | name + '', |
@@ -75,6 +75,55 @@ export class FormValidatorService { | |||
75 | } | 75 | } |
76 | } | 76 | } |
77 | 77 | ||
78 | addControlInFormArray (options: { | ||
79 | formErrors: FormReactiveErrors | ||
80 | validationMessages: FormReactiveValidationMessages | ||
81 | formArray: FormArray | ||
82 | controlName: string | ||
83 | formToBuild: BuildFormArgument | ||
84 | defaultValues?: BuildFormDefaultValues | ||
85 | }) { | ||
86 | const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options | ||
87 | |||
88 | const formGroup = new FormGroup({}) | ||
89 | if (!formErrors[controlName]) formErrors[controlName] = [] as FormReactiveErrors[] | ||
90 | if (!validationMessages[controlName]) validationMessages[controlName] = [] | ||
91 | |||
92 | const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] | ||
93 | const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] | ||
94 | |||
95 | const totalControls = formArray.controls.length | ||
96 | formArrayErrors.push({}) | ||
97 | formArrayValidationMessages.push({}) | ||
98 | |||
99 | this.updateFormGroup( | ||
100 | formGroup, | ||
101 | formArrayErrors[totalControls], | ||
102 | formArrayValidationMessages[totalControls], | ||
103 | formToBuild, | ||
104 | defaultValues | ||
105 | ) | ||
106 | |||
107 | formArray.push(formGroup) | ||
108 | } | ||
109 | |||
110 | removeControlFromFormArray (options: { | ||
111 | formErrors: FormReactiveErrors | ||
112 | validationMessages: FormReactiveValidationMessages | ||
113 | index: number | ||
114 | formArray: FormArray | ||
115 | controlName: string | ||
116 | }) { | ||
117 | const { formArray, formErrors, validationMessages, index, controlName } = options | ||
118 | |||
119 | const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] | ||
120 | const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] | ||
121 | |||
122 | formArrayErrors.splice(index, 1) | ||
123 | formArrayValidationMessages.splice(index, 1) | ||
124 | formArray.removeAt(index) | ||
125 | } | ||
126 | |||
78 | updateTreeValidity (group: FormGroup | FormArray): void { | 127 | updateTreeValidity (group: FormGroup | FormArray): void { |
79 | for (const key of Object.keys(group.controls)) { | 128 | for (const key of Object.keys(group.controls)) { |
80 | // FIXME: typings | 129 | // FIXME: typings |
diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts index be03f25b9..2f3c8f603 100644 --- a/client/src/app/shared/shared-forms/input-text.component.ts +++ b/client/src/app/shared/shared-forms/input-text.component.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { Notifier } from '@app/core' | 3 | import { FormReactiveErrors } from './form-reactive.service' |
4 | 4 | ||
5 | @Component({ | 5 | @Component({ |
6 | selector: 'my-input-text', | 6 | selector: 'my-input-text', |
@@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor { | |||
26 | @Input() withCopy = false | 26 | @Input() withCopy = false |
27 | @Input() readonly = false | 27 | @Input() readonly = false |
28 | @Input() show = false | 28 | @Input() show = false |
29 | @Input() formError: string | 29 | @Input() formError: string | FormReactiveErrors | FormReactiveErrors[] |
30 | |||
31 | constructor (private notifier: Notifier) { } | ||
32 | 30 | ||
33 | get inputType () { | 31 | get inputType () { |
34 | return this.show | 32 | return this.show |
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html index ac2dfd17c..7f8bd2f62 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.html +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html | |||
@@ -25,7 +25,7 @@ | |||
25 | </ng-template> | 25 | </ng-template> |
26 | </ng-container> | 26 | </ng-container> |
27 | 27 | ||
28 | <button (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled"> | 28 | <button type="button" (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled"> |
29 | <my-global-icon *ngIf="!isMaximized" [ngbTooltip]="maximizeInText" iconName="fullscreen"></my-global-icon> | 29 | <my-global-icon *ngIf="!isMaximized" [ngbTooltip]="maximizeInText" iconName="fullscreen"></my-global-icon> |
30 | 30 | ||
31 | <my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-fullscreen"></my-global-icon> | 31 | <my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-fullscreen"></my-global-icon> |
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts index 169be39d1..77e6cbd8c 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts | |||
@@ -6,6 +6,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | |||
6 | import { SafeHtml } from '@angular/platform-browser' | 6 | import { SafeHtml } from '@angular/platform-browser' |
7 | import { MarkdownService, ScreenService } from '@app/core' | 7 | import { MarkdownService, ScreenService } from '@app/core' |
8 | import { Video } from '@peertube/peertube-models' | 8 | import { Video } from '@peertube/peertube-models' |
9 | import { FormReactiveErrors } from './form-reactive.service' | ||
9 | 10 | ||
10 | @Component({ | 11 | @Component({ |
11 | selector: 'my-markdown-textarea', | 12 | selector: 'my-markdown-textarea', |
@@ -23,7 +24,7 @@ import { Video } from '@peertube/peertube-models' | |||
23 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | 24 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { |
24 | @Input() content = '' | 25 | @Input() content = '' |
25 | 26 | ||
26 | @Input() formError: string | 27 | @Input() formError: string | FormReactiveErrors | FormReactiveErrors[] |
27 | 28 | ||
28 | @Input() truncateTo3Lines: boolean | 29 | @Input() truncateTo3Lines: boolean |
29 | 30 | ||
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss index e69a06947..df19240b4 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.scss +++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss | |||
@@ -4,6 +4,7 @@ | |||
4 | p-inputmask { | 4 | p-inputmask { |
5 | ::ng-deep input { | 5 | ::ng-deep input { |
6 | width: 80px; | 6 | width: 80px; |
7 | text-align: center; | ||
7 | 8 | ||
8 | &:focus-within, | 9 | &:focus-within, |
9 | &:focus { | 10 | &:focus { |
diff --git a/client/src/app/shared/shared-main/buttons/button.component.html b/client/src/app/shared/shared-main/buttons/button.component.html index d87e35876..9270c0925 100644 --- a/client/src/app/shared/shared-main/buttons/button.component.html +++ b/client/src/app/shared/shared-main/buttons/button.component.html | |||
@@ -1,4 +1,4 @@ | |||
1 | <button *ngIf="!ptRouterLink" class="action-button" [ngClass]="classes" [ngbTooltip]="title"> | 1 | <button *ngIf="!ptRouterLink" type="button" class="action-button" [ngClass]="classes" [ngbTooltip]="title"> |
2 | <ng-container *ngTemplateOutlet="content"></ng-container> | 2 | <ng-container *ngTemplateOutlet="content"></ng-container> |
3 | </button> | 3 | </button> |
4 | 4 | ||
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 243394bda..30c6cabf5 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts | |||
@@ -49,6 +49,7 @@ import { UserHistoryService, UserNotificationsComponent, UserNotificationService | |||
49 | import { | 49 | import { |
50 | EmbedComponent, | 50 | EmbedComponent, |
51 | RedundancyService, | 51 | RedundancyService, |
52 | VideoChapterService, | ||
52 | VideoFileTokenService, | 53 | VideoFileTokenService, |
53 | VideoImportService, | 54 | VideoImportService, |
54 | VideoOwnershipService, | 55 | VideoOwnershipService, |
@@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel' | |||
215 | 216 | ||
216 | VideoPasswordService, | 217 | VideoPasswordService, |
217 | 218 | ||
219 | VideoChapterService, | ||
220 | |||
218 | CustomPageService, | 221 | CustomPageService, |
219 | 222 | ||
220 | ActorRedirectGuard | 223 | 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 59c0969a9..5e4a27d4e 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 | |||
@@ -3,9 +3,9 @@ import { catchError, map, switchMap } from 'rxjs/operators' | |||
3 | import { HttpClient } from '@angular/common/http' | 3 | import { HttpClient } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { RestExtractor, ServerService } from '@app/core' | 5 | import { RestExtractor, ServerService } from '@app/core' |
6 | import { objectToFormData, sortBy } from '@app/helpers' | 6 | import { objectToFormData } from '@app/helpers' |
7 | import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' | 7 | import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' |
8 | import { peertubeTranslate } from '@peertube/peertube-core-utils' | 8 | import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils' |
9 | import { ResultList, VideoCaption } from '@peertube/peertube-models' | 9 | import { ResultList, VideoCaption } from '@peertube/peertube-models' |
10 | import { environment } from '../../../../environments/environment' | 10 | import { environment } from '../../../../environments/environment' |
11 | import { VideoCaptionEdit } from './video-caption-edit.model' | 11 | import { VideoCaptionEdit } from './video-caption-edit.model' |
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts index 07d40b117..7414ded23 100644 --- a/client/src/app/shared/shared-main/video/index.ts +++ b/client/src/app/shared/shared-main/video/index.ts | |||
@@ -1,5 +1,7 @@ | |||
1 | export * from './embed.component' | 1 | export * from './embed.component' |
2 | export * from './redundancy.service' | 2 | export * from './redundancy.service' |
3 | export * from './video-chapter.service' | ||
4 | export * from './video-chapters-edit.model' | ||
3 | export * from './video-details.model' | 5 | export * from './video-details.model' |
4 | export * from './video-edit.model' | 6 | export * from './video-edit.model' |
5 | export * from './video-file-token.service' | 7 | export * from './video-file-token.service' |
diff --git a/client/src/app/shared/shared-main/video/video-chapter.service.ts b/client/src/app/shared/shared-main/video/video-chapter.service.ts new file mode 100644 index 000000000..6d221c9e9 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-chapter.service.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import { catchError } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor } from '@app/core' | ||
5 | import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' | ||
6 | import { VideoPasswordService } from './video-password.service' | ||
7 | import { VideoService } from './video.service' | ||
8 | import { VideoChaptersEdit } from './video-chapters-edit.model' | ||
9 | import { of } from 'rxjs' | ||
10 | |||
11 | @Injectable() | ||
12 | export class VideoChapterService { | ||
13 | |||
14 | constructor ( | ||
15 | private authHttp: HttpClient, | ||
16 | private restExtractor: RestExtractor | ||
17 | ) {} | ||
18 | |||
19 | getChapters (options: { videoId: string, videoPassword?: string }) { | ||
20 | const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword) | ||
21 | |||
22 | return this.authHttp.get<{ chapters: VideoChapter[] }>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}/chapters`, { headers }) | ||
23 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
24 | } | ||
25 | |||
26 | updateChapters (videoId: string, chaptersEdit: VideoChaptersEdit) { | ||
27 | if (chaptersEdit.shouldUpdateAPI() !== true) return of(true) | ||
28 | |||
29 | const body = { chapters: chaptersEdit.getChaptersForUpdate() } as VideoChapterUpdate | ||
30 | |||
31 | return this.authHttp.put(`${VideoService.BASE_VIDEO_URL}/${videoId}/chapters`, body) | ||
32 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
33 | } | ||
34 | } | ||
diff --git a/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts b/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts new file mode 100644 index 000000000..6d7496ed6 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-chapters-edit.model.ts | |||
@@ -0,0 +1,43 @@ | |||
1 | import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils' | ||
2 | import { VideoChapter } from '@peertube/peertube-models' | ||
3 | |||
4 | export class VideoChaptersEdit { | ||
5 | private chaptersFromAPI: VideoChapter[] = [] | ||
6 | |||
7 | private chapters: VideoChapter[] | ||
8 | |||
9 | loadFromAPI (chapters: VideoChapter[]) { | ||
10 | this.chapters = chapters || [] | ||
11 | |||
12 | this.chaptersFromAPI = chapters | ||
13 | } | ||
14 | |||
15 | patch (values: { [ id: string ]: any }) { | ||
16 | const chapters = values.chapters || [] | ||
17 | |||
18 | this.chapters = chapters.map((c: any) => { | ||
19 | return { | ||
20 | timecode: c.timecode || 0, | ||
21 | title: c.title | ||
22 | } | ||
23 | }) | ||
24 | } | ||
25 | |||
26 | toFormPatch () { | ||
27 | return { chapters: this.chapters } | ||
28 | } | ||
29 | |||
30 | getChaptersForUpdate (): VideoChapter[] { | ||
31 | return this.chapters.filter(c => !!c.title) | ||
32 | } | ||
33 | |||
34 | hasDuplicateValues () { | ||
35 | const timecodes = this.chapters.map(c => c.timecode) | ||
36 | |||
37 | return new Set(timecodes).size !== this.chapters.length | ||
38 | } | ||
39 | |||
40 | shouldUpdateAPI () { | ||
41 | return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true | ||
42 | } | ||
43 | } | ||
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index 111b4645b..192b2e124 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts | |||
@@ -7,6 +7,8 @@ import './shared/bezels/bezels-plugin' | |||
7 | import './shared/peertube/peertube-plugin' | 7 | import './shared/peertube/peertube-plugin' |
8 | import './shared/resolutions/peertube-resolutions-plugin' | 8 | import './shared/resolutions/peertube-resolutions-plugin' |
9 | import './shared/control-bar/storyboard-plugin' | 9 | import './shared/control-bar/storyboard-plugin' |
10 | import './shared/control-bar/chapters-plugin' | ||
11 | import './shared/control-bar/time-tooltip' | ||
10 | import './shared/control-bar/next-previous-video-button' | 12 | import './shared/control-bar/next-previous-video-button' |
11 | import './shared/control-bar/p2p-info-button' | 13 | import './shared/control-bar/p2p-info-button' |
12 | import './shared/control-bar/peertube-link-button' | 14 | import './shared/control-bar/peertube-link-button' |
@@ -227,6 +229,7 @@ export class PeerTubePlayer { | |||
227 | if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() | 229 | if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() |
228 | if (this.player.usingPlugin('stats')) this.player.stats().dispose() | 230 | if (this.player.usingPlugin('stats')) this.player.stats().dispose() |
229 | if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() | 231 | if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() |
232 | if (this.player.usingPlugin('chapters')) this.player.chapters().dispose() | ||
230 | 233 | ||
231 | if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() | 234 | if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() |
232 | 235 | ||
@@ -273,6 +276,10 @@ export class PeerTubePlayer { | |||
273 | this.player.storyboard(this.currentLoadOptions.storyboard) | 276 | this.player.storyboard(this.currentLoadOptions.storyboard) |
274 | } | 277 | } |
275 | 278 | ||
279 | if (this.currentLoadOptions.videoChapters) { | ||
280 | this.player.chapters({ chapters: this.currentLoadOptions.videoChapters }) | ||
281 | } | ||
282 | |||
276 | if (this.currentLoadOptions.dock) { | 283 | if (this.currentLoadOptions.dock) { |
277 | this.player.peertubeDock(this.currentLoadOptions.dock) | 284 | this.player.peertubeDock(this.currentLoadOptions.dock) |
278 | } | 285 | } |
diff --git a/client/src/assets/player/shared/control-bar/chapters-plugin.ts b/client/src/assets/player/shared/control-bar/chapters-plugin.ts new file mode 100644 index 000000000..5be081694 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/chapters-plugin.ts | |||
@@ -0,0 +1,64 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { ChaptersOptions } from '../../types' | ||
3 | import { VideoChapter } from '@peertube/peertube-models' | ||
4 | import { ProgressBarMarkerComponent } from './progress-bar-marker-component' | ||
5 | |||
6 | const Plugin = videojs.getPlugin('plugin') | ||
7 | |||
8 | class ChaptersPlugin extends Plugin { | ||
9 | private chapters: VideoChapter[] = [] | ||
10 | private markers: ProgressBarMarkerComponent[] = [] | ||
11 | |||
12 | constructor (player: videojs.Player, options: videojs.ComponentOptions & ChaptersOptions) { | ||
13 | super(player, options) | ||
14 | |||
15 | this.chapters = options.chapters | ||
16 | |||
17 | this.player.ready(() => { | ||
18 | player.addClass('vjs-chapters') | ||
19 | |||
20 | this.player.one('durationchange', () => { | ||
21 | for (const chapter of this.chapters) { | ||
22 | if (chapter.timecode === 0) continue | ||
23 | |||
24 | const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode }) | ||
25 | |||
26 | this.markers.push(marker) | ||
27 | this.getSeekBar().addChild(marker) | ||
28 | } | ||
29 | }) | ||
30 | }) | ||
31 | } | ||
32 | |||
33 | dispose () { | ||
34 | for (const marker of this.markers) { | ||
35 | this.getSeekBar().removeChild(marker) | ||
36 | } | ||
37 | } | ||
38 | |||
39 | getChapter (timecode: number) { | ||
40 | if (this.chapters.length !== 0) { | ||
41 | for (let i = this.chapters.length - 1; i >= 0; i--) { | ||
42 | const chapter = this.chapters[i] | ||
43 | |||
44 | if (chapter.timecode <= timecode) { | ||
45 | this.player.addClass('has-chapter') | ||
46 | |||
47 | return chapter.title | ||
48 | } | ||
49 | } | ||
50 | } | ||
51 | |||
52 | this.player.removeClass('has-chapter') | ||
53 | |||
54 | return '' | ||
55 | } | ||
56 | |||
57 | private getSeekBar () { | ||
58 | return this.player.getDescendant('ControlBar', 'ProgressControl', 'SeekBar') | ||
59 | } | ||
60 | } | ||
61 | |||
62 | videojs.registerPlugin('chapters', ChaptersPlugin) | ||
63 | |||
64 | export { ChaptersPlugin } | ||
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 9307027f6..091e876e2 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts | |||
@@ -1,6 +1,8 @@ | |||
1 | export * from './chapters-plugin' | ||
1 | export * from './next-previous-video-button' | 2 | export * from './next-previous-video-button' |
2 | export * from './p2p-info-button' | 3 | export * from './p2p-info-button' |
3 | export * from './peertube-link-button' | 4 | export * from './peertube-link-button' |
4 | export * from './peertube-live-display' | 5 | export * from './peertube-live-display' |
5 | export * from './storyboard-plugin' | 6 | export * from './storyboard-plugin' |
6 | export * from './theater-button' | 7 | export * from './theater-button' |
8 | export * from './time-tooltip' | ||
diff --git a/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts new file mode 100644 index 000000000..50965ec71 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { ProgressBarMarkerComponentOptions } from '../../types' | ||
3 | |||
4 | const Component = videojs.getComponent('Component') | ||
5 | |||
6 | export class ProgressBarMarkerComponent extends Component { | ||
7 | options_: ProgressBarMarkerComponentOptions & videojs.ComponentOptions | ||
8 | |||
9 | // eslint-disable-next-line @typescript-eslint/no-useless-constructor | ||
10 | constructor (player: videojs.Player, options?: ProgressBarMarkerComponentOptions & videojs.ComponentOptions) { | ||
11 | super(player, options) | ||
12 | } | ||
13 | |||
14 | createEl () { | ||
15 | const left = (this.options_.timecode / this.player().duration()) * 100 | ||
16 | |||
17 | return videojs.dom.createEl('span', { | ||
18 | className: 'vjs-marker', | ||
19 | style: `left: ${left}%` | ||
20 | }) as HTMLButtonElement | ||
21 | } | ||
22 | } | ||
23 | |||
24 | videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent) | ||
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 80c69b5f2..91d7f451e 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts | |||
@@ -141,7 +141,9 @@ class StoryboardPlugin extends Plugin { | |||
141 | const ctop = Math.floor(position / columns) * -scaledHeight | 141 | const ctop = Math.floor(position / columns) * -scaledHeight |
142 | 142 | ||
143 | const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px` | 143 | const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px` |
144 | const topOffset = -scaledHeight - 60 | 144 | |
145 | const timeTooltip = this.player.el().querySelector('.vjs-time-tooltip') | ||
146 | const topOffset = -scaledHeight + parseInt(getComputedStyle(timeTooltip).top.replace('px', '')) - 20 | ||
145 | 147 | ||
146 | const previewHalfSize = Math.round(scaledWidth / 2) | 148 | const previewHalfSize = Math.round(scaledWidth / 2) |
147 | let left = seekBarRect.width * seekBarX - previewHalfSize | 149 | let left = seekBarRect.width * seekBarX - previewHalfSize |
diff --git a/client/src/assets/player/shared/control-bar/time-tooltip.ts b/client/src/assets/player/shared/control-bar/time-tooltip.ts new file mode 100644 index 000000000..2ed4f9acd --- /dev/null +++ b/client/src/assets/player/shared/control-bar/time-tooltip.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | import { timeToInt } from '@peertube/peertube-core-utils' | ||
2 | import videojs, { VideoJsPlayer } from 'video.js' | ||
3 | |||
4 | const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method | ||
5 | |||
6 | class TimeTooltip extends TimeToolTip { | ||
7 | |||
8 | write (timecode: string) { | ||
9 | const player: VideoJsPlayer = this.player() | ||
10 | |||
11 | if (player.usingPlugin('chapters')) { | ||
12 | const chapterTitle = player.chapters().getChapter(timeToInt(timecode)) | ||
13 | if (chapterTitle) return super.write(chapterTitle + '\r\n' + timecode) | ||
14 | } | ||
15 | |||
16 | return super.write(timecode) | ||
17 | } | ||
18 | } | ||
19 | |||
20 | videojs.registerComponent('TimeTooltip', TimeTooltip) | ||
diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts index 6fb2f7913..32f26fa9e 100644 --- a/client/src/assets/player/types/peertube-player-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models' | 1 | import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models' |
2 | import { PluginsManager } from '@root-helpers/plugins-manager' | 2 | import { PluginsManager } from '@root-helpers/plugins-manager' |
3 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | 3 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' |
4 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' | 4 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' |
@@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = { | |||
68 | } | 68 | } |
69 | 69 | ||
70 | videoCaptions: VideoJSCaption[] | 70 | videoCaptions: VideoJSCaption[] |
71 | videoChapters: VideoChapter[] | ||
71 | storyboard: VideoJSStoryboard | 72 | storyboard: VideoJSStoryboard |
72 | 73 | ||
73 | videoUUID: string | 74 | videoUUID: string |
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 27fbda31d..6293404ab 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { HlsConfig, Level } from 'hls.js' | 1 | import { HlsConfig, Level } from 'hls.js' |
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' | 3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' |
4 | import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' | 4 | import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' |
5 | import { BezelsPlugin } from '../shared/bezels/bezels-plugin' | 5 | import { BezelsPlugin } from '../shared/bezels/bezels-plugin' |
6 | import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' | 6 | import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' |
7 | import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | 7 | import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' |
@@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin' | |||
19 | import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' | 19 | import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' |
20 | import { PlayerMode } from './peertube-player-options' | 20 | import { PlayerMode } from './peertube-player-options' |
21 | import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' | 21 | import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' |
22 | import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin' | ||
22 | 23 | ||
23 | declare module 'video.js' { | 24 | declare module 'video.js' { |
24 | 25 | ||
@@ -62,6 +63,8 @@ declare module 'video.js' { | |||
62 | 63 | ||
63 | peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin | 64 | peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin |
64 | 65 | ||
66 | chapters (options?: ChaptersOptions): ChaptersPlugin | ||
67 | |||
65 | upnext (options?: UpNextPluginOptions): UpNextPlugin | 68 | upnext (options?: UpNextPluginOptions): UpNextPlugin |
66 | 69 | ||
67 | playlist (options?: PlaylistPluginOptions): PlaylistPlugin | 70 | playlist (options?: PlaylistPluginOptions): PlaylistPlugin |
@@ -142,6 +145,10 @@ type StoryboardOptions = { | |||
142 | interval: number | 145 | interval: number |
143 | } | 146 | } |
144 | 147 | ||
148 | type ChaptersOptions = { | ||
149 | chapters: VideoChapter[] | ||
150 | } | ||
151 | |||
145 | type PlaylistPluginOptions = { | 152 | type PlaylistPluginOptions = { |
146 | elements: VideoPlaylistElement[] | 153 | elements: VideoPlaylistElement[] |
147 | 154 | ||
@@ -161,6 +168,10 @@ type UpNextPluginOptions = { | |||
161 | isSuspended: () => boolean | 168 | isSuspended: () => boolean |
162 | } | 169 | } |
163 | 170 | ||
171 | type ProgressBarMarkerComponentOptions = { | ||
172 | timecode: number | ||
173 | } | ||
174 | |||
164 | type NextPreviousVideoButtonOptions = { | 175 | type NextPreviousVideoButtonOptions = { |
165 | type: 'next' | 'previous' | 176 | type: 'next' | 'previous' |
166 | handler?: () => void | 177 | handler?: () => void |
@@ -273,6 +284,7 @@ export { | |||
273 | NextPreviousVideoButtonOptions, | 284 | NextPreviousVideoButtonOptions, |
274 | ResolutionUpdateData, | 285 | ResolutionUpdateData, |
275 | AutoResolutionUpdateData, | 286 | AutoResolutionUpdateData, |
287 | ProgressBarMarkerComponentOptions, | ||
276 | PlaylistPluginOptions, | 288 | PlaylistPluginOptions, |
277 | MetricsPluginOptions, | 289 | MetricsPluginOptions, |
278 | VideoJSCaption, | 290 | VideoJSCaption, |
@@ -284,5 +296,6 @@ export { | |||
284 | UpNextPluginOptions, | 296 | UpNextPluginOptions, |
285 | LoadedQualityData, | 297 | LoadedQualityData, |
286 | StoryboardOptions, | 298 | StoryboardOptions, |
299 | ChaptersOptions, | ||
287 | PeerTubeLinkButtonOptions | 300 | PeerTubeLinkButtonOptions |
288 | } | 301 | } |
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss index 09a75e2fd..f272f3848 100644 --- a/client/src/sass/player/control-bar.scss +++ b/client/src/sass/player/control-bar.scss | |||
@@ -3,6 +3,16 @@ | |||
3 | @use '_mixins' as *; | 3 | @use '_mixins' as *; |
4 | @use './_player-variables' as *; | 4 | @use './_player-variables' as *; |
5 | 5 | ||
6 | .vjs-peertube-skin.has-chapter { | ||
7 | .vjs-time-tooltip { | ||
8 | white-space: pre; | ||
9 | line-height: 1.5; | ||
10 | padding-top: 4px; | ||
11 | padding-bottom: 4px; | ||
12 | top: -4.9em; | ||
13 | } | ||
14 | } | ||
15 | |||
6 | .video-js.vjs-peertube-skin .vjs-control-bar { | 16 | .video-js.vjs-peertube-skin .vjs-control-bar { |
7 | z-index: 100; | 17 | z-index: 100; |
8 | 18 | ||
@@ -495,3 +505,12 @@ | |||
495 | } | 505 | } |
496 | } | 506 | } |
497 | } | 507 | } |
508 | |||
509 | .vjs-marker { | ||
510 | position: absolute; | ||
511 | width: 3px; | ||
512 | opacity: .5; | ||
513 | background-color: #000; | ||
514 | height: 100%; | ||
515 | top: 0; | ||
516 | } | ||
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index e4f723079..78c5e5592 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts | |||
@@ -195,10 +195,11 @@ export class PeerTubeEmbed { | |||
195 | const { | 195 | const { |
196 | videoResponse, | 196 | videoResponse, |
197 | captionsPromise, | 197 | captionsPromise, |
198 | chaptersPromise, | ||
198 | storyboardsPromise | 199 | storyboardsPromise |
199 | } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) | 200 | } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) |
200 | 201 | ||
201 | return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay }) | 202 | return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay }) |
202 | } catch (err) { | 203 | } catch (err) { |
203 | 204 | ||
204 | if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) | 205 | if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) |
@@ -210,9 +211,10 @@ export class PeerTubeEmbed { | |||
210 | videoResponse: Response | 211 | videoResponse: Response |
211 | storyboardsPromise: Promise<Response> | 212 | storyboardsPromise: Promise<Response> |
212 | captionsPromise: Promise<Response> | 213 | captionsPromise: Promise<Response> |
214 | chaptersPromise: Promise<Response> | ||
213 | forceAutoplay: boolean | 215 | forceAutoplay: boolean |
214 | }) { | 216 | }) { |
215 | const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options | 217 | const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options |
216 | 218 | ||
217 | const videoInfoPromise = videoResponse.json() | 219 | const videoInfoPromise = videoResponse.json() |
218 | .then(async (videoInfo: VideoDetails) => { | 220 | .then(async (videoInfo: VideoDetails) => { |
@@ -233,11 +235,13 @@ export class PeerTubeEmbed { | |||
233 | { video, live, videoFileToken }, | 235 | { video, live, videoFileToken }, |
234 | translations, | 236 | translations, |
235 | captionsResponse, | 237 | captionsResponse, |
238 | chaptersResponse, | ||
236 | storyboardsResponse | 239 | storyboardsResponse |
237 | ] = await Promise.all([ | 240 | ] = await Promise.all([ |
238 | videoInfoPromise, | 241 | videoInfoPromise, |
239 | this.translationsPromise, | 242 | this.translationsPromise, |
240 | captionsPromise, | 243 | captionsPromise, |
244 | chaptersPromise, | ||
241 | storyboardsPromise, | 245 | storyboardsPromise, |
242 | this.buildPlayerIfNeeded() | 246 | this.buildPlayerIfNeeded() |
243 | ]) | 247 | ]) |
@@ -260,6 +264,7 @@ export class PeerTubeEmbed { | |||
260 | const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({ | 264 | const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({ |
261 | video, | 265 | video, |
262 | captionsResponse, | 266 | captionsResponse, |
267 | chaptersResponse, | ||
263 | translations, | 268 | translations, |
264 | 269 | ||
265 | storyboardsResponse, | 270 | storyboardsResponse, |
diff --git a/client/src/standalone/videos/shared/player-options-builder.ts b/client/src/standalone/videos/shared/player-options-builder.ts index 3437ef421..dec859409 100644 --- a/client/src/standalone/videos/shared/player-options-builder.ts +++ b/client/src/standalone/videos/shared/player-options-builder.ts | |||
@@ -5,6 +5,7 @@ import { | |||
5 | Storyboard, | 5 | Storyboard, |
6 | Video, | 6 | Video, |
7 | VideoCaption, | 7 | VideoCaption, |
8 | VideoChapter, | ||
8 | VideoDetails, | 9 | VideoDetails, |
9 | VideoPlaylistElement, | 10 | VideoPlaylistElement, |
10 | VideoState, | 11 | VideoState, |
@@ -199,6 +200,8 @@ export class PlayerOptionsBuilder { | |||
199 | 200 | ||
200 | storyboardsResponse: Response | 201 | storyboardsResponse: Response |
201 | 202 | ||
203 | chaptersResponse: Response | ||
204 | |||
202 | live?: LiveVideo | 205 | live?: LiveVideo |
203 | 206 | ||
204 | alreadyPlayed: boolean | 207 | alreadyPlayed: boolean |
@@ -229,12 +232,14 @@ export class PlayerOptionsBuilder { | |||
229 | forceAutoplay, | 232 | forceAutoplay, |
230 | playlist, | 233 | playlist, |
231 | live, | 234 | live, |
232 | storyboardsResponse | 235 | storyboardsResponse, |
236 | chaptersResponse | ||
233 | } = options | 237 | } = options |
234 | 238 | ||
235 | const [ videoCaptions, storyboard ] = await Promise.all([ | 239 | const [ videoCaptions, storyboard, chapters ] = await Promise.all([ |
236 | this.buildCaptions(captionsResponse, translations), | 240 | this.buildCaptions(captionsResponse, translations), |
237 | this.buildStoryboard(storyboardsResponse) | 241 | this.buildStoryboard(storyboardsResponse), |
242 | this.buildChapters(chaptersResponse) | ||
238 | ]) | 243 | ]) |
239 | 244 | ||
240 | return { | 245 | return { |
@@ -248,6 +253,7 @@ export class PlayerOptionsBuilder { | |||
248 | subtitle: this.subtitle, | 253 | subtitle: this.subtitle, |
249 | 254 | ||
250 | storyboard, | 255 | storyboard, |
256 | videoChapters: chapters, | ||
251 | 257 | ||
252 | startTime: playlist | 258 | startTime: playlist |
253 | ? playlist.playlistTracker.getCurrentElement().startTimestamp | 259 | ? playlist.playlistTracker.getCurrentElement().startTimestamp |
@@ -312,6 +318,12 @@ export class PlayerOptionsBuilder { | |||
312 | } | 318 | } |
313 | } | 319 | } |
314 | 320 | ||
321 | private async buildChapters (chaptersResponse: Response) { | ||
322 | const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] } | ||
323 | |||
324 | return chapters | ||
325 | } | ||
326 | |||
315 | private buildPlaylistOptions (options?: { | 327 | private buildPlaylistOptions (options?: { |
316 | playlistTracker: PlaylistTracker | 328 | playlistTracker: PlaylistTracker |
317 | playNext: () => any | 329 | playNext: () => any |
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts index 9149d946e..c52861189 100644 --- a/client/src/standalone/videos/shared/video-fetcher.ts +++ b/client/src/standalone/videos/shared/video-fetcher.ts | |||
@@ -36,9 +36,10 @@ export class VideoFetcher { | |||
36 | } | 36 | } |
37 | 37 | ||
38 | const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) | 38 | const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) |
39 | const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword }) | ||
39 | const storyboardsPromise = this.loadStoryboards(videoId) | 40 | const storyboardsPromise = this.loadStoryboards(videoId) |
40 | 41 | ||
41 | return { captionsPromise, storyboardsPromise, videoResponse } | 42 | return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse } |
42 | } | 43 | } |
43 | 44 | ||
44 | loadLive (video: VideoDetails) { | 45 | loadLive (video: VideoDetails) { |
@@ -64,6 +65,10 @@ export class VideoFetcher { | |||
64 | return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) | 65 | return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) |
65 | } | 66 | } |
66 | 67 | ||
68 | private loadVideoChapters ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> { | ||
69 | return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword) | ||
70 | } | ||
71 | |||
67 | private getVideoUrl (id: string) { | 72 | private getVideoUrl (id: string) { |
68 | return window.location.origin + '/api/v1/videos/' + id | 73 | return window.location.origin + '/api/v1/videos/' + id |
69 | } | 74 | } |