aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
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/src/app/shared
parent7113f32a87bd6b2868154fed20bde1a1633c190e (diff)
downloadPeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.gz
PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.tar.zst
PeerTube-77b70702d2193d78bf6fbd07f0fc7335e34957f8.zip
Add video chapters support
Diffstat (limited to 'client/src/app/shared')
-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
14 files changed, 180 insertions, 25 deletions
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}