From 77b70702d2193d78bf6fbd07f0fc7335e34957f8 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 28 Aug 2023 10:55:04 +0200 Subject: Add video chapters support --- .../+video-edit/shared/video-edit.component.html | 52 +++++++++++- .../+video-edit/shared/video-edit.component.scss | 26 ++++++ .../+video-edit/shared/video-edit.component.ts | 98 +++++++++++++++++++--- .../video-go-live.component.ts | 7 +- .../video-import-torrent.component.ts | 34 ++++---- .../video-import-url.component.html | 1 + .../video-import-url.component.ts | 19 +++-- .../+video-edit/video-add-components/video-send.ts | 28 ++++++- .../video-add-components/video-upload.component.ts | 7 +- .../+video-edit/video-update.component.html | 1 + .../+videos/+video-edit/video-update.component.ts | 33 ++++++-- .../+videos/+video-edit/video-update.resolver.ts | 13 ++- .../+videos/+video-watch/video-watch.component.ts | 16 +++- client/src/app/helpers/utils/object.ts | 12 --- client/src/app/menu/language-chooser.component.ts | 4 +- .../form-validators/video-chapter-validators.ts | 32 +++++++ .../app/shared/form-validators/video-validators.ts | 8 -- .../shared/shared-forms/form-reactive.service.ts | 6 +- .../shared/shared-forms/form-validator.service.ts | 59 +++++++++++-- .../shared/shared-forms/input-text.component.ts | 6 +- .../shared-forms/markdown-textarea.component.html | 2 +- .../shared-forms/markdown-textarea.component.ts | 3 +- .../shared-forms/timestamp-input.component.scss | 1 + .../shared-main/buttons/button.component.html | 2 +- .../app/shared/shared-main/shared-main.module.ts | 3 + .../video-caption/video-caption.service.ts | 4 +- client/src/app/shared/shared-main/video/index.ts | 2 + .../shared-main/video/video-chapter.service.ts | 34 ++++++++ .../shared-main/video/video-chapters-edit.model.ts | 43 ++++++++++ client/src/assets/player/peertube-player.ts | 7 ++ .../player/shared/control-bar/chapters-plugin.ts | 64 ++++++++++++++ .../src/assets/player/shared/control-bar/index.ts | 2 + .../control-bar/progress-bar-marker-component.ts | 24 ++++++ .../player/shared/control-bar/storyboard-plugin.ts | 4 +- .../player/shared/control-bar/time-tooltip.ts | 20 +++++ .../assets/player/types/peertube-player-options.ts | 3 +- .../player/types/peertube-videojs-typings.ts | 15 +++- client/src/sass/player/control-bar.scss | 19 +++++ client/src/standalone/videos/embed.ts | 9 +- .../videos/shared/player-options-builder.ts | 18 +++- .../src/standalone/videos/shared/video-fetcher.ts | 7 +- 41 files changed, 646 insertions(+), 102 deletions(-) create mode 100644 client/src/app/shared/form-validators/video-chapter-validators.ts create mode 100644 client/src/app/shared/shared-main/video/video-chapter.service.ts create mode 100644 client/src/app/shared/shared-main/video/video-chapters-edit.model.ts create mode 100644 client/src/assets/player/shared/control-bar/chapters-plugin.ts create mode 100644 client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts create mode 100644 client/src/assets/player/shared/control-bar/time-tooltip.ts (limited to 'client/src') 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 @@ + + Chapters + + +
+
+ +
+ +
+ + + + + +
+ + +
{{ i + 1 }}
+ + + +
+ + +
+ t + {{ formErrors.chapters[i].title }} +
+
+ + +
+
+ +
+ {{ getChapterArrayErrors() }} +
+
+ + +
+
+
+ Live settings @@ -312,7 +363,6 @@ - Advanced settings 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 { @include orange-button; } +.hide-chapter-label { + height: 0; + opacity: 0; +} + +.chapter { + display: grid; + grid-template-columns: auto auto minmax(150px, 350px) 1fr; + grid-template-rows: auto auto; + column-gap: 1rem; + + .position { + height: 31px; + display: flex; + align-items: center; + } + + my-delete-button { + width: fit-content; + } + + .form-error { + margin-top: 0; + } +} + @include on-small-main-col { .form-columns { 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' import { map } from 'rxjs/operators' import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model' import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' -import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' +import { AbstractControl, FormArray, FormGroup, Validators } from '@angular/forms' import { HooksService, PluginService, ServerService } from '@app/core' import { removeElementFromArray } from '@app/helpers' -import { BuildFormValidator } from '@app/shared/form-validators' +import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators' import { VIDEO_CATEGORY_VALIDATOR, VIDEO_CHANNEL_VALIDATOR, @@ -20,9 +20,10 @@ import { VIDEO_SUPPORT_VALIDATOR, VIDEO_TAGS_ARRAY_VALIDATOR } from '@app/shared/form-validators/video-validators' -import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' +import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' +import { FormReactiveErrors, FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms' import { InstanceService } from '@app/shared/shared-instance' -import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoEdit, VideoService } from '@app/shared/shared-main' +import { VideoCaptionEdit, VideoCaptionWithPathEdit, VideoChaptersEdit, VideoEdit, VideoService } from '@app/shared/shared-main' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { HTMLServerConfig, @@ -30,6 +31,7 @@ import { LiveVideoLatencyMode, RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions, + VideoChapter, VideoConstant, VideoDetails, VideoPrivacy, @@ -57,7 +59,7 @@ type PluginField = { }) export class VideoEditComponent implements OnInit, OnDestroy { @Input() form: FormGroup - @Input() formErrors: { [ id: string ]: string } = {} + @Input() formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {} @Input() validationMessages: FormReactiveValidationMessages = {} @Input() videoToUpdate: VideoDetails @@ -68,6 +70,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { @Input() videoCaptions: VideoCaptionWithPathEdit[] = [] @Input() videoSource: VideoSource + @Input() videoChapters: VideoChapter[] = [] + @Input() hideWaitTranscoding = false @Input() updateVideoFileEnabled = false @@ -150,7 +154,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { licence: this.serverConfig.defaults.publish.licence, tags: [] } - const obj: { [ id: string ]: BuildFormValidator } = { + const obj: BuildFormArgument = { name: VIDEO_NAME_VALIDATOR, privacy: VIDEO_PRIVACY_VALIDATOR, videoPassword: VIDEO_PASSWORD_VALIDATOR, @@ -183,12 +187,16 @@ export class VideoEditComponent implements OnInit, OnDestroy { defaultValues ) - this.form.addControl('captions', new FormArray([ - new FormGroup({ - language: new FormControl(), - captionfile: new FormControl() - }) - ])) + this.form.addControl('chapters', new FormArray([], VIDEO_CHAPTERS_ARRAY_VALIDATOR.VALIDATORS)) + this.addNewChapterControl() + + this.form.get('chapters').valueChanges.subscribe((chapters: { title: string, timecode: string }[]) => { + const lastChapter = chapters[chapters.length - 1] + + if (lastChapter.title || lastChapter.timecode) { + this.addNewChapterControl() + } + }) this.trackChannelChange() this.trackPrivacyChange() @@ -426,6 +434,70 @@ export class VideoEditComponent implements OnInit, OnDestroy { this.form.valueChanges.subscribe(() => this.formValidatorService.updateTreeValidity(this.pluginDataFormGroup)) } + // --------------------------------------------------------------------------- + + addNewChapterControl () { + const chaptersFormArray = this.getChaptersFormArray() + const controls = chaptersFormArray.controls + + if (controls.length !== 0) { + const lastControl = chaptersFormArray.controls[controls.length - 1] + lastControl.get('title').addValidators(Validators.required) + } + + this.formValidatorService.addControlInFormArray({ + controlName: 'chapters', + formArray: chaptersFormArray, + formErrors: this.formErrors, + validationMessages: this.validationMessages, + formToBuild: { + timecode: null, + title: VIDEO_CHAPTER_TITLE_VALIDATOR + }, + defaultValues: { + timecode: 0 + } + }) + } + + getChaptersFormArray () { + return this.form.controls['chapters'] as FormArray + } + + deleteChapterControl (index: number) { + this.formValidatorService.removeControlFromFormArray({ + controlName: 'chapters', + formArray: this.getChaptersFormArray(), + formErrors: this.formErrors, + validationMessages: this.validationMessages, + index + }) + } + + isLastChapterControl (index: number) { + return this.getChaptersFormArray().length - 1 === index + } + + patchChapters (chaptersEdit: VideoChaptersEdit) { + const totalChapters = chaptersEdit.getChaptersForUpdate().length + const totalControls = this.getChaptersFormArray().length + + // Add missing controls. We use <= because we need the "empty control" to add another chapter + for (let i = 0; i <= totalChapters - totalControls; i++) { + this.addNewChapterControl() + } + + this.form.patchValue(chaptersEdit.toFormPatch()) + } + + getChapterArrayErrors () { + if (!this.getChaptersFormArray().errors) return '' + + return Object.values(this.getChaptersFormArray().errors).join('. ') + } + + // --------------------------------------------------------------------------- + private trackPrivacyChange () { // We will update the schedule input and the wait transcoding checkbox validators this.form.controls['privacy'] @@ -469,8 +541,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { } else { videoPasswordControl.clearValidators() } - videoPasswordControl.updateValueAndValidity() + videoPasswordControl.updateValueAndValidity() } ) } 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' import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' import { scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' import { LiveVideoService } from '@app/shared/shared-video-live' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' @@ -54,6 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private liveVideoService: LiveVideoService, private router: Router, private hooks: HooksService @@ -137,6 +138,8 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView video.uuid = this.videoUUID video.shortUUID = this.videoShortUUID + this.chaptersEdit.patch(this.form.value) + const saveReplay = this.form.value.saveReplay const replaySettings = saveReplay ? { privacy: this.form.value.replayPrivacy } @@ -151,7 +154,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView // Update the video forkJoin([ - this.updateVideoAndCaptions(video), + this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions }), this.liveVideoService.updateLive(this.videoId, liveVideoUpdate) ]).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' import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' import { scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' +import { VideoCaptionService, VideoChapterService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' import { PeerTubeProblemDocument, ServerErrorCode, VideoUpdate } from '@peertube/peertube-models' @@ -42,6 +42,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private router: Router, private videoImportService: VideoImportService, private hooks: HooksService @@ -124,24 +125,25 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af if (!await this.isFormValid()) return this.video.patch(this.form.value) + this.chaptersEdit.patch(this.form.value) this.isUpdatingVideo = true // Update the video - this.updateVideoAndCaptions(this.video) - .subscribe({ - next: () => { - this.isUpdatingVideo = false - this.notifier.success($localize`Video to import updated.`) - - this.router.navigate([ '/my-library', 'video-imports' ]) - }, - - error: err => { - this.error = err.message - scrollToTop() - logger.error(err) - } - }) + this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit }) + .subscribe({ + next: () => { + this.isUpdatingVideo = false + this.notifier.success($localize`Video to import updated.`) + + this.router.navigate([ '/my-library', 'video-imports' ]) + }, + + error: err => { + this.error = err.message + scrollToTop() + logger.error(err) + } + }) } } 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 @@
() @Output() firstStepError = new EventEmitter() @@ -41,6 +44,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private router: Router, private videoImportService: VideoImportService, private hooks: HooksService @@ -85,12 +89,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV switchMap(previous => { return forkJoin([ this.videoCaptionService.listCaptions(previous.video.uuid), + this.videoChapterService.getChapters({ videoId: previous.video.uuid }), this.videoService.getVideo({ videoId: previous.video.uuid }) - ]).pipe(map(([ videoCaptionsResult, video ]) => ({ videoCaptions: videoCaptionsResult.data, video }))) + ]).pipe(map(([ videoCaptionsResult, { chapters }, video ]) => ({ videoCaptions: videoCaptionsResult.data, chapters, video }))) }) ) .subscribe({ - next: ({ video, videoCaptions }) => { + next: ({ video, videoCaptions, chapters }) => { this.loadingBar.useRef().complete() this.firstStepDone.emit(video.name) this.isImportingVideo = false @@ -99,9 +104,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV this.video = new VideoEdit(video) this.video.patch({ privacy: this.firstStepPrivacyId }) + this.chaptersEdit.loadFromAPI(chapters) + this.videoCaptions = videoCaptions hydrateFormFromVideo(this.form, this.video, true) + setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit)) }, error: err => { @@ -117,11 +125,12 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV if (!await this.isFormValid()) return this.video.patch(this.form.value) + this.chaptersEdit.patch(this.form.value) this.isUpdatingVideo = true // Update the video - this.updateVideoAndCaptions(this.video) + this.updateVideoAndCaptionsAndChapters({ video: this.video, captions: this.videoCaptions, chapters: this.chaptersEdit }) .subscribe({ next: () => { 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' import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' import { listUserChannelsForSelect } from '@app/helpers' import { FormReactive } from '@app/shared/shared-forms' -import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { + VideoCaptionEdit, + VideoCaptionService, + VideoChapterService, + VideoChaptersEdit, + VideoEdit, + VideoService +} from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { HTMLServerConfig, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models' +import { of } from 'rxjs' @Directive() // eslint-disable-next-line @angular-eslint/directive-class-suffix @@ -14,6 +22,7 @@ export abstract class VideoSend extends FormReactive implements OnInit { userVideoChannels: SelectChannelItem[] = [] videoPrivacies: VideoConstant[] = [] videoCaptions: VideoCaptionEdit[] = [] + chaptersEdit = new VideoChaptersEdit() firstStepPrivacyId: VideoPrivacyType firstStepChannelId: number @@ -28,6 +37,7 @@ export abstract class VideoSend extends FormReactive implements OnInit { protected serverService: ServerService protected videoService: VideoService protected videoCaptionService: VideoCaptionService + protected videoChapterService: VideoChapterService protected serverConfig: HTMLServerConfig @@ -60,13 +70,23 @@ export abstract class VideoSend extends FormReactive implements OnInit { }) } - protected updateVideoAndCaptions (video: VideoEdit) { + protected updateVideoAndCaptionsAndChapters (options: { + video: VideoEdit + captions: VideoCaptionEdit[] + chapters?: VideoChaptersEdit + }) { + const { video, captions, chapters } = options + this.loadingBar.useRef().start() return this.videoService.updateVideo(video) .pipe( - // Then update captions - switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)), + switchMap(() => this.videoCaptionService.updateCaptions(video.uuid, captions)), + switchMap(() => { + return chapters + ? this.videoChapterService.updateChapters(video.uuid, chapters) + : of(true) + }), tap(() => this.loadingBar.useRef().complete()), catchError(err => { 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' import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' import { FormReactiveService } from '@app/shared/shared-forms' -import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoChapterService, VideoEdit, VideoService } from '@app/shared/shared-main' import { LoadingBarService } from '@ngx-loading-bar/core' import { logger } from '@root-helpers/logger' import { HttpStatusCode, VideoCreateResult } from '@peertube/peertube-models' @@ -63,6 +63,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy protected serverService: ServerService, protected videoService: VideoService, protected videoCaptionService: VideoCaptionService, + protected videoChapterService: VideoChapterService, private userService: UserService, private router: Router, private hooks: HooksService, @@ -241,9 +242,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy video.uuid = this.videoUploadedIds.uuid video.shortUUID = this.videoUploadedIds.shortUUID + this.chaptersEdit.patch(this.form.value) + this.isUpdatingVideo = true - this.updateVideoAndCaptions(video) + this.updateVideoAndCaptionsAndChapters({ video, captions: this.videoCaptions, chapters: this.chaptersEdit }) .subscribe({ next: () => { 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 @@ this.onUploadVideoOngoing(state)) const { videoData } = this.route.snapshot.data - const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData + const { video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword } = videoData this.videoDetails = video this.videoEdit = new VideoEdit(this.videoDetails, videoPassword) + this.chaptersEdit.loadFromAPI(videoChapters) this.userVideoChannels = videoChannels this.videoCaptions = videoCaptions @@ -106,6 +122,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest onFormBuilt () { hydrateFormFromVideo(this.form, this.videoEdit, true) + setTimeout(() => this.videoEditComponent.patchChapters(this.chaptersEdit)) + if (this.liveVideo) { this.form.patchValue({ saveReplay: this.liveVideo.saveReplay, @@ -172,6 +190,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest if (!await this.checkAndConfirmVideoFileReplacement()) return this.videoEdit.patch(this.form.value) + this.chaptersEdit.patch(this.form.value) this.abortUpdateIfNeeded() @@ -180,10 +199,12 @@ export class VideoUpdateComponent extends FormReactive implements OnInit, OnDest this.updateSubcription = this.videoReplacementUploadedSubject.pipe( switchMap(() => this.videoService.updateVideo(this.videoEdit)), + switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.uuid, this.videoCaptions)), + switchMap(() => { + if (this.liveVideo) return of(true) - // Then update captions - switchMap(() => this.videoCaptionService.updateCaptions(this.videoEdit.id, this.videoCaptions)), - + return this.videoChapterService.updateChapters(this.videoEdit.uuid, this.chaptersEdit) + }), switchMap(() => { if (!this.liveVideo) return of(undefined) 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' import { ActivatedRouteSnapshot } from '@angular/router' import { AuthService } from '@app/core' import { listUserChannelsForSelect } from '@app/helpers' -import { VideoCaptionService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' +import { VideoCaptionService, VideoChapterService, VideoDetails, VideoPasswordService, VideoService } from '@app/shared/shared-main' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPrivacy } from '@peertube/peertube-models' @@ -15,6 +15,7 @@ export class VideoUpdateResolver { private liveVideoService: LiveVideoService, private authService: AuthService, private videoCaptionService: VideoCaptionService, + private videoChapterService: VideoChapterService, private videoPasswordService: VideoPasswordService ) { } @@ -25,8 +26,8 @@ export class VideoUpdateResolver { return this.videoService.getVideo({ videoId: uuid }) .pipe( switchMap(video => forkJoin(this.buildVideoObservables(video))), - map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) => - ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword })) + map(([ video, videoSource, videoChannels, videoCaptions, videoChapters, liveVideo, videoPassword ]) => + ({ video, videoChannels, videoCaptions, videoChapters, videoSource, liveVideo, videoPassword })) ) } @@ -46,6 +47,12 @@ export class VideoUpdateResolver { map(result => result.data) ), + this.videoChapterService + .getChapters({ videoId: video.uuid }) + .pipe( + map(({ chapters }) => chapters) + ), + video.isLive ? this.liveVideoService.getVideoLive(video.id) : 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 { } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' -import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' +import { Video, VideoCaptionService, VideoChapterService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { LiveVideoService } from '@app/shared/shared-video-live' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' @@ -31,6 +31,7 @@ import { ServerErrorCode, Storyboard, VideoCaption, + VideoChapter, VideoPrivacy, VideoState, VideoStateType @@ -83,6 +84,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails = null videoCaptions: VideoCaption[] = [] + videoChapters: VideoChapter[] = [] liveVideo: LiveVideo videoPassword: string storyboards: Storyboard[] = [] @@ -125,6 +127,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private notifier: Notifier, private zone: NgZone, private videoCaptionService: VideoCaptionService, + private videoChapterService: VideoChapterService, private hotkeysService: HotkeysService, private hooks: HooksService, private pluginService: PluginService, @@ -306,14 +309,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy { forkJoin([ videoAndLiveObs, this.videoCaptionService.listCaptions(videoId, videoPassword), + this.videoChapterService.getChapters({ videoId, videoPassword }), this.videoService.getStoryboards(videoId, videoPassword), this.userService.getAnonymousOrLoggedUser() ]).subscribe({ - next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { + next: ([ { video, live, videoFileToken }, captionsResult, chaptersResult, storyboards, loggedInOrAnonymousUser ]) => { this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, + videoChapters: chaptersResult.chapters, storyboards, videoFileToken, videoPassword, @@ -411,6 +416,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails live: LiveVideo videoCaptions: VideoCaption[] + videoChapters: VideoChapter[] storyboards: Storyboard[] videoFileToken: string videoPassword: string @@ -422,6 +428,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video, live, videoCaptions, + videoChapters, storyboards, videoFileToken, videoPassword, @@ -433,6 +440,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.video = video this.videoCaptions = videoCaptions + this.videoChapters = videoChapters this.liveVideo = live this.videoFileToken = videoFileToken this.videoPassword = videoPassword @@ -480,6 +488,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { const params = { video: this.video, videoCaptions: this.videoCaptions, + videoChapters: this.videoChapters, storyboards: this.storyboards, liveVideo: this.liveVideo, videoFileToken: this.videoFileToken, @@ -636,6 +645,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails liveVideo: LiveVideo videoCaptions: VideoCaption[] + videoChapters: VideoChapter[] storyboards: Storyboard[] videoFileToken: string @@ -651,6 +661,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video, liveVideo, videoCaptions, + videoChapters, storyboards, videoFileToken, videoPassword, @@ -750,6 +761,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoPassword: () => videoPassword, videoCaptions: playerCaptions, + videoChapters, storyboard, 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 (arr: T[], elem: T) { if (index !== -1) arr.splice(index, 1) } -function sortBy (obj: any[], key1: string, key2?: string) { - return obj.sort((a, b) => { - const elem1 = key2 ? a[key1][key2] : a[key1] - const elem2 = key2 ? b[key1][key2] : b[key1] - - if (elem1 < elem2) return -1 - if (elem1 === elem2) return 0 - return 1 - }) -} - function splitIntoArray (value: any) { if (!value) return undefined if (Array.isArray(value)) return value @@ -41,7 +30,6 @@ function toBoolean (value: any) { } export { - sortBy, immutableAssign, removeElementFromArray, 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 @@ import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' -import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers' +import { getDevLocale, isOnDevLocale } from '@app/helpers' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped } from '@peertube/peertube-core-utils' +import { getCompleteLocale, getShortLocale, I18N_LOCALES, objectKeysTyped, sortBy } from '@peertube/peertube-core-utils' @Component({ 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 @@ +import { AbstractControl, ValidationErrors, ValidatorFn, Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.model' + +export const VIDEO_CHAPTER_TITLE_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically + MESSAGES: { + required: $localize`A chapter title is required.`, + minlength: $localize`A chapter title should be more than 2 characters long.`, + maxlength: $localize`A chapter title should be less than 100 characters long.` + } +} + +export const VIDEO_CHAPTERS_ARRAY_VALIDATOR: BuildFormValidator = { + VALIDATORS: [ uniqueTimecodeValidator() ], + MESSAGES: {} +} + +function uniqueTimecodeValidator (): ValidatorFn { + return (control: AbstractControl): ValidationErrors => { + const array = control.value as { timecode: number, title: string }[] + + for (const chapter of array) { + if (!chapter.title) continue + + if (array.filter(c => c.title && c.timecode === chapter.timecode).length > 1) { + return { uniqueTimecode: $localize`Multiple chapters have the same timecode ${chapter.timecode}` } + } + } + + return null + } +} 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 = { } } -export const VIDEO_TAG_VALIDATOR: BuildFormValidator = { - VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], - MESSAGES: { - minlength: $localize`A tag should be more than 2 characters long.`, - maxlength: $localize`A tag should be less than 30 characters long.` - } -} - export const VIDEO_TAGS_ARRAY_VALIDATOR: BuildFormValidator = { VALIDATORS: [ Validators.maxLength(5), arrayTagLengthValidator() ], 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' import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { FormValidatorService } from './form-validator.service' -export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } +export type FormReactiveErrors = { [ id: string | number ]: string | FormReactiveErrors | FormReactiveErrors[] } export type FormReactiveValidationMessages = { - [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages + [ id: string | number ]: { [ name: string ]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[] } @Injectable() @@ -86,7 +86,7 @@ export class FormReactiveService { if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue - const staticMessages = validationMessages[field] + const staticMessages = validationMessages[field] as FormReactiveValidationMessages for (const key of Object.keys(control.errors)) { const formErrorValue = control.errors[key] 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 { form: FormGroup, formErrors: FormReactiveErrors, validationMessages: FormReactiveValidationMessages, - obj: BuildFormArgument, + formToBuild: BuildFormArgument, defaultValues: BuildFormDefaultValues = {} ) { - for (const name of objectKeysTyped(obj)) { + for (const name of objectKeysTyped(formToBuild)) { formErrors[name] = '' - const field = obj[name] + const field = formToBuild[name] if (this.isRecursiveField(field)) { this.updateFormGroup( // FIXME: typings (form as any)[name], formErrors[name] as FormReactiveErrors, validationMessages[name] as FormReactiveValidationMessages, - obj[name] as BuildFormArgument, + formToBuild[name] as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues ) continue @@ -66,7 +66,7 @@ export class FormValidatorService { if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } - const defaultValue = defaultValues[name] || '' + const defaultValue = defaultValues[name] ?? '' form.addControl( name + '', @@ -75,6 +75,55 @@ export class FormValidatorService { } } + addControlInFormArray (options: { + formErrors: FormReactiveErrors + validationMessages: FormReactiveValidationMessages + formArray: FormArray + controlName: string + formToBuild: BuildFormArgument + defaultValues?: BuildFormDefaultValues + }) { + const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options + + const formGroup = new FormGroup({}) + if (!formErrors[controlName]) formErrors[controlName] = [] as FormReactiveErrors[] + if (!validationMessages[controlName]) validationMessages[controlName] = [] + + const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] + const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] + + const totalControls = formArray.controls.length + formArrayErrors.push({}) + formArrayValidationMessages.push({}) + + this.updateFormGroup( + formGroup, + formArrayErrors[totalControls], + formArrayValidationMessages[totalControls], + formToBuild, + defaultValues + ) + + formArray.push(formGroup) + } + + removeControlFromFormArray (options: { + formErrors: FormReactiveErrors + validationMessages: FormReactiveValidationMessages + index: number + formArray: FormArray + controlName: string + }) { + const { formArray, formErrors, validationMessages, index, controlName } = options + + const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] + const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] + + formArrayErrors.splice(index, 1) + formArrayValidationMessages.splice(index, 1) + formArray.removeAt(index) + } + updateTreeValidity (group: FormGroup | FormArray): void { for (const key of Object.keys(group.controls)) { // 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 @@ import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' -import { Notifier } from '@app/core' +import { FormReactiveErrors } from './form-reactive.service' @Component({ selector: 'my-input-text', @@ -26,9 +26,7 @@ export class InputTextComponent implements ControlValueAccessor { @Input() withCopy = false @Input() readonly = false @Input() show = false - @Input() formError: string - - constructor (private notifier: Notifier) { } + @Input() formError: string | FormReactiveErrors | FormReactiveErrors[] get inputType () { 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 @@ - 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 import { EmbedComponent, RedundancyService, + VideoChapterService, VideoFileTokenService, VideoImportService, VideoOwnershipService, @@ -215,6 +216,8 @@ import { VideoChannelService } from './video-channel' VideoPasswordService, + VideoChapterService, + CustomPageService, 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' import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, ServerService } from '@app/core' -import { objectToFormData, sortBy } from '@app/helpers' +import { objectToFormData } from '@app/helpers' import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video' -import { peertubeTranslate } from '@peertube/peertube-core-utils' +import { peertubeTranslate, sortBy } from '@peertube/peertube-core-utils' import { ResultList, VideoCaption } from '@peertube/peertube-models' import { environment } from '../../../../environments/environment' import { VideoCaptionEdit } from './video-caption-edit.model' diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts index 07d40b117..7414ded23 100644 --- a/client/src/app/shared/shared-main/video/index.ts +++ b/client/src/app/shared/shared-main/video/index.ts @@ -1,5 +1,7 @@ export * from './embed.component' export * from './redundancy.service' +export * from './video-chapter.service' +export * from './video-chapters-edit.model' export * from './video-details.model' export * from './video-edit.model' export * from './video-file-token.service' diff --git a/client/src/app/shared/shared-main/video/video-chapter.service.ts b/client/src/app/shared/shared-main/video/video-chapter.service.ts new file mode 100644 index 000000000..6d221c9e9 --- /dev/null +++ b/client/src/app/shared/shared-main/video/video-chapter.service.ts @@ -0,0 +1,34 @@ +import { catchError } from 'rxjs/operators' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { VideoChapter, VideoChapterUpdate } from '@peertube/peertube-models' +import { VideoPasswordService } from './video-password.service' +import { VideoService } from './video.service' +import { VideoChaptersEdit } from './video-chapters-edit.model' +import { of } from 'rxjs' + +@Injectable() +export class VideoChapterService { + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) {} + + getChapters (options: { videoId: string, videoPassword?: string }) { + const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword) + + return this.authHttp.get<{ chapters: VideoChapter[] }>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}/chapters`, { headers }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + updateChapters (videoId: string, chaptersEdit: VideoChaptersEdit) { + if (chaptersEdit.shouldUpdateAPI() !== true) return of(true) + + const body = { chapters: chaptersEdit.getChaptersForUpdate() } as VideoChapterUpdate + + return this.authHttp.put(`${VideoService.BASE_VIDEO_URL}/${videoId}/chapters`, body) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} 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 @@ +import { simpleObjectsDeepEqual, sortBy } from '@peertube/peertube-core-utils' +import { VideoChapter } from '@peertube/peertube-models' + +export class VideoChaptersEdit { + private chaptersFromAPI: VideoChapter[] = [] + + private chapters: VideoChapter[] + + loadFromAPI (chapters: VideoChapter[]) { + this.chapters = chapters || [] + + this.chaptersFromAPI = chapters + } + + patch (values: { [ id: string ]: any }) { + const chapters = values.chapters || [] + + this.chapters = chapters.map((c: any) => { + return { + timecode: c.timecode || 0, + title: c.title + } + }) + } + + toFormPatch () { + return { chapters: this.chapters } + } + + getChaptersForUpdate (): VideoChapter[] { + return this.chapters.filter(c => !!c.title) + } + + hasDuplicateValues () { + const timecodes = this.chapters.map(c => c.timecode) + + return new Set(timecodes).size !== this.chapters.length + } + + shouldUpdateAPI () { + return simpleObjectsDeepEqual(sortBy(this.getChaptersForUpdate(), 'timecode'), this.chaptersFromAPI) !== true + } +} 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' import './shared/peertube/peertube-plugin' import './shared/resolutions/peertube-resolutions-plugin' import './shared/control-bar/storyboard-plugin' +import './shared/control-bar/chapters-plugin' +import './shared/control-bar/time-tooltip' import './shared/control-bar/next-previous-video-button' import './shared/control-bar/p2p-info-button' import './shared/control-bar/peertube-link-button' @@ -227,6 +229,7 @@ export class PeerTubePlayer { if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() if (this.player.usingPlugin('stats')) this.player.stats().dispose() if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() + if (this.player.usingPlugin('chapters')) this.player.chapters().dispose() if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() @@ -273,6 +276,10 @@ export class PeerTubePlayer { this.player.storyboard(this.currentLoadOptions.storyboard) } + if (this.currentLoadOptions.videoChapters) { + this.player.chapters({ chapters: this.currentLoadOptions.videoChapters }) + } + if (this.currentLoadOptions.dock) { this.player.peertubeDock(this.currentLoadOptions.dock) } 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 @@ +import videojs from 'video.js' +import { ChaptersOptions } from '../../types' +import { VideoChapter } from '@peertube/peertube-models' +import { ProgressBarMarkerComponent } from './progress-bar-marker-component' + +const Plugin = videojs.getPlugin('plugin') + +class ChaptersPlugin extends Plugin { + private chapters: VideoChapter[] = [] + private markers: ProgressBarMarkerComponent[] = [] + + constructor (player: videojs.Player, options: videojs.ComponentOptions & ChaptersOptions) { + super(player, options) + + this.chapters = options.chapters + + this.player.ready(() => { + player.addClass('vjs-chapters') + + this.player.one('durationchange', () => { + for (const chapter of this.chapters) { + if (chapter.timecode === 0) continue + + const marker = new ProgressBarMarkerComponent(player, { timecode: chapter.timecode }) + + this.markers.push(marker) + this.getSeekBar().addChild(marker) + } + }) + }) + } + + dispose () { + for (const marker of this.markers) { + this.getSeekBar().removeChild(marker) + } + } + + getChapter (timecode: number) { + if (this.chapters.length !== 0) { + for (let i = this.chapters.length - 1; i >= 0; i--) { + const chapter = this.chapters[i] + + if (chapter.timecode <= timecode) { + this.player.addClass('has-chapter') + + return chapter.title + } + } + } + + this.player.removeClass('has-chapter') + + return '' + } + + private getSeekBar () { + return this.player.getDescendant('ControlBar', 'ProgressControl', 'SeekBar') + } +} + +videojs.registerPlugin('chapters', ChaptersPlugin) + +export { ChaptersPlugin } diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 9307027f6..091e876e2 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts @@ -1,6 +1,8 @@ +export * from './chapters-plugin' export * from './next-previous-video-button' export * from './p2p-info-button' export * from './peertube-link-button' export * from './peertube-live-display' export * from './storyboard-plugin' export * from './theater-button' +export * from './time-tooltip' diff --git a/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts new file mode 100644 index 000000000..50965ec71 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/progress-bar-marker-component.ts @@ -0,0 +1,24 @@ +import videojs from 'video.js' +import { ProgressBarMarkerComponentOptions } from '../../types' + +const Component = videojs.getComponent('Component') + +export class ProgressBarMarkerComponent extends Component { + options_: ProgressBarMarkerComponentOptions & videojs.ComponentOptions + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor (player: videojs.Player, options?: ProgressBarMarkerComponentOptions & videojs.ComponentOptions) { + super(player, options) + } + + createEl () { + const left = (this.options_.timecode / this.player().duration()) * 100 + + return videojs.dom.createEl('span', { + className: 'vjs-marker', + style: `left: ${left}%` + }) as HTMLButtonElement + } +} + +videojs.registerComponent('ProgressBarMarkerComponent', ProgressBarMarkerComponent) diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 80c69b5f2..91d7f451e 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts @@ -141,7 +141,9 @@ class StoryboardPlugin extends Plugin { const ctop = Math.floor(position / columns) * -scaledHeight const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px` - const topOffset = -scaledHeight - 60 + + const timeTooltip = this.player.el().querySelector('.vjs-time-tooltip') + const topOffset = -scaledHeight + parseInt(getComputedStyle(timeTooltip).top.replace('px', '')) - 20 const previewHalfSize = Math.round(scaledWidth / 2) 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 @@ +import { timeToInt } from '@peertube/peertube-core-utils' +import videojs, { VideoJsPlayer } from 'video.js' + +const TimeToolTip = videojs.getComponent('TimeTooltip') as any // FIXME: typings don't have write method + +class TimeTooltip extends TimeToolTip { + + write (timecode: string) { + const player: VideoJsPlayer = this.player() + + if (player.usingPlugin('chapters')) { + const chapterTitle = player.chapters().getChapter(timeToInt(timecode)) + if (chapterTitle) return super.write(chapterTitle + '\r\n' + timecode) + } + + return super.write(timecode) + } +} + +videojs.registerComponent('TimeTooltip', TimeTooltip) diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts index 6fb2f7913..32f26fa9e 100644 --- a/client/src/assets/player/types/peertube-player-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts @@ -1,4 +1,4 @@ -import { LiveVideoLatencyModeType, VideoFile } from '@peertube/peertube-models' +import { LiveVideoLatencyModeType, VideoChapter, VideoFile } from '@peertube/peertube-models' import { PluginsManager } from '@root-helpers/plugins-manager' import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' @@ -68,6 +68,7 @@ export type PeerTubePlayerLoadOptions = { } videoCaptions: VideoJSCaption[] + videoChapters: VideoChapter[] storyboard: VideoJSStoryboard 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 @@ import { HlsConfig, Level } from 'hls.js' import videojs from 'video.js' import { Engine } from '@peertube/p2p-media-loader-hlsjs' -import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' +import { VideoChapter, VideoFile, VideoPlaylist, VideoPlaylistElement } from '@peertube/peertube-models' import { BezelsPlugin } from '../shared/bezels/bezels-plugin' import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' @@ -19,6 +19,7 @@ import { UpNextPlugin } from '../shared/upnext/upnext-plugin' import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' import { PlayerMode } from './peertube-player-options' import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' +import { ChaptersPlugin } from '../shared/control-bar/chapters-plugin' declare module 'video.js' { @@ -62,6 +63,8 @@ declare module 'video.js' { peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin + chapters (options?: ChaptersOptions): ChaptersPlugin + upnext (options?: UpNextPluginOptions): UpNextPlugin playlist (options?: PlaylistPluginOptions): PlaylistPlugin @@ -142,6 +145,10 @@ type StoryboardOptions = { interval: number } +type ChaptersOptions = { + chapters: VideoChapter[] +} + type PlaylistPluginOptions = { elements: VideoPlaylistElement[] @@ -161,6 +168,10 @@ type UpNextPluginOptions = { isSuspended: () => boolean } +type ProgressBarMarkerComponentOptions = { + timecode: number +} + type NextPreviousVideoButtonOptions = { type: 'next' | 'previous' handler?: () => void @@ -273,6 +284,7 @@ export { NextPreviousVideoButtonOptions, ResolutionUpdateData, AutoResolutionUpdateData, + ProgressBarMarkerComponentOptions, PlaylistPluginOptions, MetricsPluginOptions, VideoJSCaption, @@ -284,5 +296,6 @@ export { UpNextPluginOptions, LoadedQualityData, StoryboardOptions, + ChaptersOptions, PeerTubeLinkButtonOptions } 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 @@ @use '_mixins' as *; @use './_player-variables' as *; +.vjs-peertube-skin.has-chapter { + .vjs-time-tooltip { + white-space: pre; + line-height: 1.5; + padding-top: 4px; + padding-bottom: 4px; + top: -4.9em; + } +} + .video-js.vjs-peertube-skin .vjs-control-bar { z-index: 100; @@ -495,3 +505,12 @@ } } } + +.vjs-marker { + position: absolute; + width: 3px; + opacity: .5; + background-color: #000; + height: 100%; + top: 0; +} 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 { const { videoResponse, captionsPromise, + chaptersPromise, storyboardsPromise } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword }) - return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay }) + return this.buildVideoPlayer({ videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay }) } catch (err) { if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options }) @@ -210,9 +211,10 @@ export class PeerTubeEmbed { videoResponse: Response storyboardsPromise: Promise captionsPromise: Promise + chaptersPromise: Promise forceAutoplay: boolean }) { - const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options + const { videoResponse, captionsPromise, chaptersPromise, storyboardsPromise, forceAutoplay } = options const videoInfoPromise = videoResponse.json() .then(async (videoInfo: VideoDetails) => { @@ -233,11 +235,13 @@ export class PeerTubeEmbed { { video, live, videoFileToken }, translations, captionsResponse, + chaptersResponse, storyboardsResponse ] = await Promise.all([ videoInfoPromise, this.translationsPromise, captionsPromise, + chaptersPromise, storyboardsPromise, this.buildPlayerIfNeeded() ]) @@ -260,6 +264,7 @@ export class PeerTubeEmbed { const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({ video, captionsResponse, + chaptersResponse, translations, 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 { Storyboard, Video, VideoCaption, + VideoChapter, VideoDetails, VideoPlaylistElement, VideoState, @@ -199,6 +200,8 @@ export class PlayerOptionsBuilder { storyboardsResponse: Response + chaptersResponse: Response + live?: LiveVideo alreadyPlayed: boolean @@ -229,12 +232,14 @@ export class PlayerOptionsBuilder { forceAutoplay, playlist, live, - storyboardsResponse + storyboardsResponse, + chaptersResponse } = options - const [ videoCaptions, storyboard ] = await Promise.all([ + const [ videoCaptions, storyboard, chapters ] = await Promise.all([ this.buildCaptions(captionsResponse, translations), - this.buildStoryboard(storyboardsResponse) + this.buildStoryboard(storyboardsResponse), + this.buildChapters(chaptersResponse) ]) return { @@ -248,6 +253,7 @@ export class PlayerOptionsBuilder { subtitle: this.subtitle, storyboard, + videoChapters: chapters, startTime: playlist ? playlist.playlistTracker.getCurrentElement().startTimestamp @@ -312,6 +318,12 @@ export class PlayerOptionsBuilder { } } + private async buildChapters (chaptersResponse: Response) { + const { chapters } = await chaptersResponse.json() as { chapters: VideoChapter[] } + + return chapters + } + private buildPlaylistOptions (options?: { playlistTracker: PlaylistTracker 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 { } const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword }) + const chaptersPromise = this.loadVideoChapters({ videoId, videoPassword }) const storyboardsPromise = this.loadStoryboards(videoId) - return { captionsPromise, storyboardsPromise, videoResponse } + return { captionsPromise, chaptersPromise, storyboardsPromise, videoResponse } } loadLive (video: VideoDetails) { @@ -64,6 +65,10 @@ export class VideoFetcher { return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword) } + private loadVideoChapters ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise { + return this.http.fetch(this.getVideoUrl(videoId) + '/chapters', { optionalAuth: true }, videoPassword) + } + private getVideoUrl (id: string) { return window.location.origin + '/api/v1/videos/' + id } -- cgit v1.2.3