aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-08-28 10:55:04 +0200
committerChocobozzz <me@florianbigard.com>2023-08-28 16:17:31 +0200
commit77b70702d2193d78bf6fbd07f0fc7335e34957f8 (patch)
tree1a0aed540054286c9a8b10c4890cc0f718e00458 /client
parent7113f32a87bd6b2868154fed20bde1a1633c190e (diff)
downloadPeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.gz
PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.zst
PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.zip
Add video chapters support
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html52
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.scss26
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts98
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts7
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts34
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html1
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts19
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-send.ts28
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts7
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.html1
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts33
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts13
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts16
-rw-r--r--client/src/app/helpers/utils/object.ts12
-rw-r--r--client/src/app/menu/language-chooser.component.ts4
-rw-r--r--client/src/app/shared/form-validators/video-chapter-validators.ts32
-rw-r--r--client/src/app/shared/form-validators/video-validators.ts8
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.service.ts6
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts59
-rw-r--r--client/src/app/shared/shared-forms/input-text.component.ts6
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.html2
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts3
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.scss1
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.html2
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts3
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts4
-rw-r--r--client/src/app/shared/shared-main/video/index.ts2
-rw-r--r--client/src/app/shared/shared-main/video/video-chapter.service.ts34
-rw-r--r--client/src/app/shared/shared-main/video/video-chapters-edit.model.ts43
-rw-r--r--client/src/assets/player/peertube-player.ts7
-rw-r--r--client/src/assets/player/shared/control-bar/chapters-plugin.ts64
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts2
-rw-r--r--client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts24
-rw-r--r--client/src/assets/player/shared/control-bar/storyboard-plugin.ts4
-rw-r--r--client/src/assets/player/shared/control-bar/time-tooltip.ts20
-rw-r--r--client/src/assets/player/types/peertube-player-options.ts3
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts15
-rw-r--r--client/src/sass/player/control-bar.scss19
-rw-r--r--client/src/standalone/videos/embed.ts9
-rw-r--r--client/src/standalone/videos/shared/player-options-builder.ts18
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts7
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'
2import { map } from 'rxjs/operators' 2import { map } from 'rxjs/operators'
3import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' 3import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
4import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' 4import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
5import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' 5import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms'
6import { HooksService, PluginService, ServerService } from '@app/core' 6import { HooksService, PluginService, ServerService } from '@app/core'
7import { removeElementFromArray } from '@app/helpers' 7import { removeElementFromArray } from '@app/helpers'
8import { BuildFormValidator } from '@app/shared/form-validators' 8import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators'
9import { 9import {
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'
23import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' 23import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
24import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
24import { InstanceService } from '@app/shared/shared-instance' 25import { InstanceService } from '@app/shared/shared-instance'
25import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main' 26import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
26import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 27import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
27import { 28import {
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})
58export class VideoEditComponent implements OnInit, OnDestroy { 60export 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'
4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormReactiveService } from '@app/shared/shared-forms' 6import { FormReactiveService } from '@app/shared/shared-forms'
7import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 7import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
10import { logger } from '@root-helpers/logger' 10import { 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'
4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormReactiveService } from '@app/shared/shared-forms' 6import { FormReactiveService } from '@app/shared/shared-forms'
7import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 8import { LoadingBarService } from '@ngx-loading-bar/core'
9import { logger } from '@root-helpers/logger' 9import { logger } from '@root-helpers/logger'
10import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' 10import { 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 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators' 2import { map, switchMap } from 'rxjs/operators'
3import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular/core' 3import { AfterViewInit, Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
4import { Router } from '@angular/router' 4import { Router } from '@angular/router'
5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
6import { scrollToTop } from '@app/helpers' 6import { scrollToTop } from '@app/helpers'
7import { FormReactiveService } from '@app/shared/shared-forms' 7import { FormReactiveService } from '@app/shared/shared-forms'
8import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' 8import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
10import { logger } from '@root-helpers/logger' 10import { logger } from '@root-helpers/logger'
11import { VideoUpdate } from '@peertube/peertube-models' 11import { VideoUpdate } from '@peertube/peertube-models'
12import { hydrateFormFromVideo } from '../shared/video-edit-utils' 12import { hydrateFormFromVideo } from '../shared/video-edit-utils'
13import { VideoSend } from './video-send' 13import { VideoSend } from './video-send'
14import { 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})
23export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterViewInit, CanComponentDeactivate { 24export 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'
4import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
5import { listUserChannelsForSelect } from '@app/helpers' 5import { listUserChannelsForSelect } from '@app/helpers'
6import { FormReactive } from '@app/shared/shared-forms' 6import { FormReactive } from '@app/shared/shared-forms'
7import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 7import {
8 VideoCaptionEdit,
9 VideoCaptionService,
10 VideoChapterService,
11 VideoChaptersEdit,
12 VideoEdit,
13 VideoService
14} from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 15import { LoadingBarService } from '@ngx-loading-bar/core'
9import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' 16import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
17import { 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'
7import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' 7import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
8import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' 8import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
9import { FormReactiveService } from '@app/shared/shared-forms' 9import { FormReactiveService } from '@app/shared/shared-forms'
10import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 10import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main'
11import { LoadingBarService } from '@ngx-loading-bar/core' 11import { LoadingBarService } from '@ngx-loading-bar/core'
12import { logger } from '@root-helpers/logger' 12import { logger } from '@root-helpers/logger'
13import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' 13import { 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'
4import { catchError, map, switchMap } from 'rxjs/operators' 4import { catchError, map, switchMap } from 'rxjs/operators'
5import { SelectChannelItem } from 'src/types/select-options-item.model' 5import { SelectChannelItem } from 'src/types/select-options-item.model'
6import { HttpErrorResponse } from '@angular/common/http' 6import { HttpErrorResponse } from '@angular/common/http'
7import { Component, HostListener, OnDestroy, OnInit } from '@angular/core' 7import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'
8import { ActivatedRoute, Router } from '@angular/router' 8import { ActivatedRoute, Router } from '@angular/router'
9import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core' 9import { AuthService, CanComponentDeactivate, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
10import { genericUploadErrorHandler } from '@app/helpers' 10import { genericUploadErrorHandler } from '@app/helpers'
11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
12import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' 12import {
13 Video,
14 VideoCaptionEdit,
15 VideoCaptionService,
16 VideoChapterService,
17 VideoChaptersEdit,
18 VideoDetails,
19 VideoEdit,
20 VideoService
21} from '@app/shared/shared-main'
13import { LiveVideoService } from '@app/shared/shared-video-live' 22import { LiveVideoService } from '@app/shared/shared-video-live'
14import { LoadingBarService } from '@ngx-loading-bar/core' 23import { LoadingBarService } from '@ngx-loading-bar/core'
15import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils' 24import { pick, simpleObjectsDeepEqual } from '@peertube/peertube-core-utils'
16import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models' 25import { HttpStatusCode, LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoSource, VideoState } from '@peertube/peertube-models'
17import { hydrateFormFromVideo } from './shared/video-edit-utils' 26import { hydrateFormFromVideo } from './shared/video-edit-utils'
18import { VideoUploadService } from './shared/video-upload.service' 27import { VideoUploadService } from './shared/video-upload.service'
28import { VideoEditComponent } from './shared/video-edit.component'
19 29
20const debugLogger = debug('peertube:video-update') 30const 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})
27export class VideoUpdateComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { 37export 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'
4import { ActivatedRouteSnapshot } from '@angular/router' 4import { ActivatedRouteSnapshot } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { listUserChannelsForSelect } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { VideoPrivacy } from '@peertube/peertube-models' 9import { 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'
19import { HooksService } from '@app/core/plugins/hooks.service' 19import { HooksService } from '@app/core/plugins/hooks.service'
20import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' 20import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
21import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' 21import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
22import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' 22import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
23import { LiveVideoService } from '@app/shared/shared-video-live' 23import { LiveVideoService } from '@app/shared/shared-video-live'
24import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 24import { 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
10function 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
21function splitIntoArray (value: any) { 10function 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
43export { 32export {
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 @@
1import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' 1import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
2import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers' 2import { getDevLocale, isOnDevLocale } from '@app/helpers'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils' 4import { 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 @@
1import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
2import { BuildFormValidator } from './form-validator.model'
3
4export 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
13export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = {
14 VALIDATORS: [ uniqueTimecodeValidator() ],
15 MESSAGES: {}
16}
17
18function 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
73export 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
81export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { 73export 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'
4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' 4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
5import { FormValidatorService } from './form-validator.service' 5import { FormValidatorService } from './form-validator.service'
6 6
7export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } 7export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] }
8export type FormReactiveValidationMessages = { 8export 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 @@
1import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' 1import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { 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'
6import { SafeHtml } from '@angular/platform-browser' 6import { SafeHtml } from '@angular/platform-browser'
7import { MarkdownService, ScreenService } from '@app/core' 7import { MarkdownService, ScreenService } from '@app/core'
8import { Video } from '@peertube/peertube-models' 8import { Video } from '@peertube/peertube-models'
9import { 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'
23export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 24export 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 @@
4p-inputmask { 4p-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
49import { 49import {
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'
3import { HttpClient } from '@angular/common/http' 3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor, ServerService } from '@app/core' 5import { RestExtractor, ServerService } from '@app/core'
6import { objectToFormData, sortBy } from '@app/helpers' 6import { objectToFormData } from '@app/helpers'
7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' 7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate } from '@peertube/peertube-core-utils' 8import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils'
9import { ResultList, VideoCaption } from '@peertube/peertube-models' 9import { ResultList, VideoCaption } from '@peertube/peertube-models'
10import { environment } from '../../../../environments/environment' 10import { environment } from '../../../../environments/environment'
11import { VideoCaptionEdit } from './video-caption-edit.model' 11import { 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 @@
1export * from './embed.component' 1export * from './embed.component'
2export * from './redundancy.service' 2export * from './redundancy.service'
3export * from './video-chapter.service'
4export * from './video-chapters-edit.model'
3export * from './video-details.model' 5export * from './video-details.model'
4export * from './video-edit.model' 6export * from './video-edit.model'
5export * from './video-file-token.service' 7export * 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 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core'
5import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models'
6import { VideoPasswordService } from './video-password.service'
7import { VideoService } from './video.service'
8import { VideoChaptersEdit } from './video-chapters-edit.model'
9import { of } from 'rxjs'
10
11@Injectable()
12export 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 @@
1import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils'
2import { VideoChapter } from '@peertube/peertube-models'
3
4export 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'
7import './shared/peertube/peertube-plugin' 7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin' 8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/storyboard-plugin' 9import './shared/control-bar/storyboard-plugin'
10import './shared/control-bar/chapters-plugin'
11import './shared/control-bar/time-tooltip'
10import './shared/control-bar/next-previous-video-button' 12import './shared/control-bar/next-previous-video-button'
11import './shared/control-bar/p2p-info-button' 13import './shared/control-bar/p2p-info-button'
12import './shared/control-bar/peertube-link-button' 14import './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 @@
1import videojs from 'video.js'
2import { ChaptersOptions } from '../../types'
3import { VideoChapter } from '@peertube/peertube-models'
4import { ProgressBarMarkerComponent } from './progress-bar-marker-component'
5
6const Plugin = videojs.getPlugin('plugin')
7
8class 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
62videojs.registerPlugin('chapters', ChaptersPlugin)
63
64export { 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 @@
1export * from './chapters-plugin'
1export * from './next-previous-video-button' 2export * from './next-previous-video-button'
2export * from './p2p-info-button' 3export * from './p2p-info-button'
3export * from './peertube-link-button' 4export * from './peertube-link-button'
4export * from './peertube-live-display' 5export * from './peertube-live-display'
5export * from './storyboard-plugin' 6export * from './storyboard-plugin'
6export * from './theater-button' 7export * from './theater-button'
8export * 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 @@
1import videojs from 'video.js'
2import { ProgressBarMarkerComponentOptions } from '../../types'
3
4const Component = videojs.getComponent('Component')
5
6export 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
24videojs.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 @@
1import { timeToInt } from '@peertube/peertube-core-utils'
2import videojs, { VideoJsPlayer } from 'video.js'
3
4const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method
5
6class 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
20videojs.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 @@
1import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models' 1import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models'
2import { PluginsManager } from '@root-helpers/plugins-manager' 2import { PluginsManager } from '@root-helpers/plugins-manager'
3import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 3import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
4import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' 4import { 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 @@
1import { HlsConfig, Level } from 'hls.js' 1import { HlsConfig, Level } from 'hls.js'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' 4import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models'
5import { BezelsPlugin } from '../shared/bezels/bezels-plugin' 5import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
6import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' 6import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
7import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 7import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
@@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
19import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' 19import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
20import { PlayerMode } from './peertube-player-options' 20import { PlayerMode } from './peertube-player-options'
21import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' 21import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
22import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin'
22 23
23declare module 'video.js' { 24declare 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
148type ChaptersOptions = {
149 chapters: VideoChapter[]
150}
151
145type PlaylistPluginOptions = { 152type 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
171type ProgressBarMarkerComponentOptions = {
172 timecode: number
173}
174
164type NextPreviousVideoButtonOptions = { 175type 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 }