From 1bca41366d668230ed5e2d4510042d0f4db81512 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 21 Aug 2024 09:53:09 +0200 Subject: [PATCH] Improve caption edition --- .../edit/video-studio-edit.component.scss | 9 +- .../video-caption-add-modal.component.html | 0 .../video-caption-add-modal.component.scss | 0 .../video-caption-add-modal.component.ts | 4 +- ...-caption-edit-modal-content.component.html | 126 +++++++ ...-caption-edit-modal-content.component.scss | 26 ++ ...eo-caption-edit-modal-content.component.ts | 345 ++++++++++++++++++ ...-caption-edit-modal-content.component.html | 36 -- ...-caption-edit-modal-content.component.scss | 4 - ...eo-caption-edit-modal-content.component.ts | 97 ----- .../shared/video-edit.component.html | 63 ++-- .../shared/video-edit.component.scss | 16 +- .../shared/video-edit.component.ts | 29 +- .../video-transcription.component.html | 2 +- .../video-transcription.component.ts | 4 +- client/src/app/helpers/utils/date.ts | 13 +- .../timestamp-input.component.html | 2 +- .../timestamp-input.component.scss | 4 +- .../shared-forms/timestamp-input.component.ts | 21 +- .../shared-icons/global-icon.component.ts | 104 +++--- .../shared/shared-main/angular/nl2br.pipe.ts | 17 + .../report-modals/report.component.scss | 4 + .../video-share.component.scss | 4 + .../video-add-to-playlist.component.scss | 4 + ...-playlist-element-miniature.component.scss | 4 + .../images/feather/clock-arrow-down.svg | 1 + .../src/assets/images/feather/move-right.svg | 1 + client/src/assets/images/feather/undo.svg | 1 + .../src/standalone/embed-player-api/player.ts | 15 + client/src/standalone/videos/embed-api.ts | 4 + packages/core-utils/src/common/date.ts | 39 +- packages/tests/src/core-utils/date.ts | 11 +- support/doc/api/embeds.md | 18 +- 33 files changed, 743 insertions(+), 285 deletions(-) rename client/src/app/+videos/+video-edit/shared/{ => caption}/video-caption-add-modal.component.html (100%) rename client/src/app/+videos/+video-edit/shared/{ => caption}/video-caption-add-modal.component.scss (100%) rename client/src/app/+videos/+video-edit/shared/{ => caption}/video-caption-add-modal.component.ts (94%) create mode 100644 client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.html create mode 100644 client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.scss create mode 100644 client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.ts delete mode 100644 client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html delete mode 100644 client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.scss delete mode 100644 client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts create mode 100644 client/src/app/shared/shared-main/angular/nl2br.pipe.ts create mode 100644 client/src/assets/images/feather/clock-arrow-down.svg create mode 100644 client/src/assets/images/feather/move-right.svg create mode 100644 client/src/assets/images/feather/undo.svg diff --git a/client/src/app/+video-studio/edit/video-studio-edit.component.scss b/client/src/app/+video-studio/edit/video-studio-edit.component.scss index eef6655da..d64d89156 100644 --- a/client/src/app/+video-studio/edit/video-studio-edit.component.scss +++ b/client/src/app/+video-studio/edit/video-studio-edit.component.scss @@ -1,6 +1,11 @@ @use '_variables' as *; @use '_mixins' as *; +my-timestamp-input { + display: block; + width: 85px; +} + .grid-container { display: grid; grid-template-columns: auto 1fr; @@ -88,10 +93,6 @@ h2 { } } -my-timestamp-input { - display: block; -} - my-embed { display: block; max-width: 500px; diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.html similarity index 100% rename from client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html rename to client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.html diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.scss similarity index 100% rename from client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss rename to client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.scss diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.ts similarity index 94% rename from client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts rename to client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.ts index 965145d84..f862d092f 100644 --- a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts +++ b/client/src/app/+videos/+video-edit/shared/caption/video-caption-add-modal.component.ts @@ -5,10 +5,10 @@ import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' import { HTMLServerConfig, VideoConstant } from '@peertube/peertube-models' -import { ReactiveFileComponent } from '../../../shared/shared-forms/reactive-file.component' +import { ReactiveFileComponent } from '../../../../shared/shared-forms/reactive-file.component' import { NgIf } from '@angular/common' import { NgSelectModule } from '@ng-select/ng-select' -import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' +import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { VideoCaptionEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model' diff --git a/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.html b/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.html new file mode 100644 index 000000000..08fc6a763 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.html @@ -0,0 +1,126 @@ + + + + + diff --git a/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.scss b/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.scss new file mode 100644 index 000000000..a5f86c994 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.scss @@ -0,0 +1,26 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.caption-raw-textarea, +.segments { + min-height: 50vh; + max-height: 50vh; + + overflow: auto; +} + +.segments textarea { + min-height: 100px; +} + +.segment-text { + &.active, + &:hover { + background: pvar(--mainBackgroundHoverColor); + } +} + +my-timestamp-input { + width: 100px; + display: inline-block; +} diff --git a/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.ts b/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.ts new file mode 100644 index 000000000..df33c07b5 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/caption/video-caption-edit-modal-content.component.ts @@ -0,0 +1,345 @@ +import { NgClass, NgForOf, NgIf } from '@angular/common' +import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators' +import { FormReactive } from '@app/shared/shared-forms/form-reactive' +import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component' +import { TimestampInputComponent } from '@app/shared/shared-forms/timestamp-input.component' +import { Nl2BrPipe } from '@app/shared/shared-main/angular/nl2br.pipe' +import { VideoCaptionEdit, VideoCaptionWithPathEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model' +import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service' +import { EmbedComponent } from '@app/shared/shared-main/video/embed.component' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { millisecondsToVttTime, sortBy, timeToInt } from '@peertube/peertube-core-utils' +import { HTMLServerConfig, Video, VideoConstant } from '@peertube/peertube-models' +import { parse } from '@plussub/srt-vtt-parser' +import { PeerTubePlayer } from '../../../../../standalone/embed-player-api/player' +import { Notifier, ServerService } from '../../../../core' +import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component' +import { ButtonComponent } from '../../../../shared/shared-main/buttons/button.component' +import { DeleteButtonComponent } from '../../../../shared/shared-main/buttons/delete-button.component' +import { EditButtonComponent } from '../../../../shared/shared-main/buttons/edit-button.component' + +type Segment = { + id: string + + startMs: number + startFormatted: string + + endMs: number + endFormatted: string + + text: string +} + +@Component({ + styleUrls: [ './video-caption-edit-modal-content.component.scss' ], + templateUrl: './video-caption-edit-modal-content.component.html', + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + GlobalIconComponent, + NgClass, + NgIf, + NgForOf, + PeertubeCheckboxComponent, + EmbedComponent, + EditButtonComponent, + ButtonComponent, + TimestampInputComponent, + DeleteButtonComponent, + Nl2BrPipe + ] +}) +export class VideoCaptionEditModalContentComponent extends FormReactive implements OnInit, AfterViewInit { + @Input() videoCaption: VideoCaptionWithPathEdit + @Input() serverConfig: HTMLServerConfig + @Input() publishedVideo: Video + + @Output() captionEdited = new EventEmitter() + + @ViewChild('textarea', { static: true }) textarea: ElementRef + @ViewChild('embed') embed: EmbedComponent + + rawEdition = false + segments: Segment[] = [] + + segmentToUpdate: Segment + segmentEditionError = '' + private segmentToUpdateSave: Segment + + activeSegment: Segment + + videoCaptionLanguages: VideoConstant[] = [] + + timestampParser = this.webvttToMS.bind(this) + timestampFormatter = millisecondsToVttTime + + private player: PeerTubePlayer + + constructor ( + protected openedModal: NgbActiveModal, + protected formReactiveService: FormReactiveService, + private videoCaptionService: VideoCaptionService, + private serverService: ServerService, + private notifier: Notifier, + private cd: ChangeDetectorRef + ) { + super() + } + + ngOnInit () { + this.serverService.getVideoLanguages().subscribe(languages => { + this.videoCaptionLanguages = languages + }) + + this.buildForm({ captionFileContent: VIDEO_CAPTION_FILE_CONTENT_VALIDATOR }) + + this.loadCaptionContent() + + this.openedModal.update({ }) + } + + ngAfterViewInit () { + if (this.embed) { + this.player = new PeerTubePlayer(this.embed.getIframe()) + + this.player.addEventListener('playbackStatusUpdate', ({ position }) => { + this.activeSegment = undefined + + if (isNaN(position)) return + + for (let i = this.segments.length - 1; i >= 0; i--) { + const current = this.segments[i] + + if (current.startMs / 1000 <= position && this.activeSegment !== current) { + this.activeSegment = current + this.cd.detectChanges() + break + } + } + }) + } + } + + // --------------------------------------------------------------------------- + + loadCaptionContent () { + this.rawEdition = false + + if (this.videoCaption.action === 'CREATE' || this.videoCaption.action === 'UPDATE') { + const file = this.videoCaption.captionfile as File + + file.text().then(content => this.loadSegments(content)) + return + } + + const { captionPath } = this.videoCaption + if (!captionPath) return + + this.videoCaptionService.getCaptionContent({ captionPath }) + .subscribe(content => { + this.loadSegments(content) + }) + } + + onRawEditionSwitch () { + if (this.rawEdition === true) { + this.form.patchValue({ captionFileContent: this.formatSegments() }) + this.resetTextarea() + } else { + this.loadSegments(this.form.value['captionFileContent']) + this.updateSegmentPositions() + } + } + + onSegmentClick (event: Event, segment: Segment) { + event.preventDefault() + + if (!this.player) return + + this.player.play() + this.player.seek(segment.startMs / 1000) + } + + onEditionSaved (segment: Segment) { + this.segmentEditionError = '' + + if (segment.startMs >= segment.endMs) { + this.segmentEditionError = $localize`Start segment must be before end segment time` + return + } + + if (!segment.text) { + this.segmentEditionError = $localize`Segment must have a text content` + return + } + + this.segmentToUpdate = undefined + + segment.startFormatted = millisecondsToVttTime(segment.startMs) + segment.endFormatted = millisecondsToVttTime(segment.endMs) + + this.updateSegmentPositions() + this.scrollToSegment(segment) + } + + onEditionCanceled (segment: Segment) { + if (!this.segmentToUpdateSave) { + this.segments = this.segments.filter(s => s.id !== segment.id) + return + } + + segment.startMs = this.segmentToUpdateSave.startMs + segment.endMs = this.segmentToUpdateSave.endMs + segment.text = this.segmentToUpdateSave.text + + this.onEditionSaved(segment) + } + + updateSegment (segment: Segment) { + this.segmentEditionError = '' + + this.segmentToUpdateSave = { ...segment } + this.segmentToUpdate = segment + } + + async addSegmentToEdit () { + const currentTime = this.player + ? await this.player.getCurrentTime() + : 0 + + const startMs = currentTime * 1000 + const endMs = startMs + 1000 + + const segment = { + startMs, + startFormatted: millisecondsToVttTime(startMs), + endMs, + endFormatted: millisecondsToVttTime(endMs), + id: '0', + text: '' + } + + this.segments = [ segment, ...this.segments ] + this.segmentToUpdate = segment + + document.querySelector('.segments').scrollTop = 0 + } + + deleteSegment (segment: Segment) { + this.segments = this.segments.filter(s => s !== segment) + this.updateSegmentPositions() + } + + private updateSegmentPositions () { + this.segments = sortBy(this.segments, 'startMs') + + for (let i = 1; i <= this.segments.length; i++) { + this.segments[i - 1].id = `${i}` + } + } + + async videoTimeForSegmentStart (segment: Segment) { + segment.startMs = await this.player.getCurrentTime() * 1000 + segment.startFormatted = millisecondsToVttTime(segment.startMs) + } + + async videoTimeForSegmentEnd (segment: Segment) { + segment.endMs = await this.player.getCurrentTime() * 1000 + segment.endFormatted = millisecondsToVttTime(segment.endMs) + } + + private webvttToMS (webvttDuration: string) { + const [ time, ms ] = webvttDuration.split('.') + + return timeToInt(time) * 1000 + parseInt(ms) + } + + private loadSegments (content: string) { + try { + const entries = parse(content).entries + + this.segments = entries.map(({ id, from, to, text }) => { + return { + id, + + startMs: from, + startFormatted: millisecondsToVttTime(from), + + endMs: to, + endFormatted: millisecondsToVttTime(to), + + text + } + }) + } catch (err) { + console.error(err) + this.notifier.error($localize`Cannot parse subtitles`) + } + } + + private formatSegments () { + let content = `WEBVTT\n` + + for (const segment of this.segments) { + content += `\n${segment.id}\n` + content += `${millisecondsToVttTime(segment.startMs)} --> ${millisecondsToVttTime(segment.endMs)}\n` + content += `${segment.text}\n` + } + + return content + } + + private resetTextarea () { + const el = this.textarea.nativeElement + + el.scrollTop = 0 + el.selectionStart = 0 + el.selectionEnd = 0 + } + + hide () { + this.openedModal.dismiss() + } + + updateCaption () { + if (this.segmentToUpdate) { + this.notifier.error($localize`A segment is being edited. Save or cancel the edition first.`) + return + } + + const languageId = this.videoCaption.language.id + const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId) + + if (this.rawEdition) { + this.loadSegments(this.form.value['captionFileContent']) + } + + this.captionEdited.emit({ + language: languageObject, + captionfile: new File([ this.formatSegments() ], `${languageId}.vtt`, { + type: 'text/vtt', + lastModified: Date.now() + }), + action: 'UPDATE' + }) + + this.openedModal.close() + } + + getSegmentClasses (segment: Segment) { + return { active: this.activeSegment === segment, ['segment-' + segment.id]: true } + } + + private scrollToSegment (segment: Segment) { + setTimeout(() => { + const element = document.querySelector('.segment-' + segment.id) + if (!element) return + + element.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }) + } +} diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html deleted file mode 100644 index 5abad6eff..000000000 --- a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.scss b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.scss deleted file mode 100644 index bd96f2b7a..000000000 --- a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -.caption-textarea { - min-height: 600px; -} - diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts deleted file mode 100644 index 90fb54c47..000000000 --- a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' -import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators' -import { FormReactive } from '@app/shared/shared-forms/form-reactive' -import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' -import { HTMLServerConfig, VideoConstant } from '@peertube/peertube-models' -import { ServerService } from '../../../../core' -import { NgClass, NgIf } from '@angular/common' -import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service' -import { VideoCaptionWithPathEdit, VideoCaptionEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model' - -/** - * https://github.com/valor-software/ngx-bootstrap/issues/3825 - * https://stackblitz.com/edit/angular-t5dfp7 - * https://medium.com/@izzatnadiri/how-to-pass-data-to-and-receive-from-ng-bootstrap-modals-916f2ad5d66e - */ -@Component({ - selector: 'my-video-caption-edit-modal-content', - styleUrls: [ './video-caption-edit-modal-content.component.scss' ], - templateUrl: './video-caption-edit-modal-content.component.html', - standalone: true, - imports: [ FormsModule, ReactiveFormsModule, GlobalIconComponent, NgClass, NgIf ] -}) - -export class VideoCaptionEditModalContentComponent extends FormReactive implements OnInit { - @Input() videoCaption: VideoCaptionWithPathEdit - @Input() serverConfig: HTMLServerConfig - - @Output() captionEdited = new EventEmitter() - - @ViewChild('textarea', { static: true }) textarea!: ElementRef - - videoCaptionLanguages: VideoConstant[] = [] - - constructor ( - protected openedModal: NgbActiveModal, - protected formReactiveService: FormReactiveService, - private videoCaptionService: VideoCaptionService, - private serverService: ServerService - ) { - super() - } - - ngOnInit () { - this.serverService.getVideoLanguages().subscribe(languages => this.videoCaptionLanguages = languages) - - this.buildForm({ captionFileContent: VIDEO_CAPTION_FILE_CONTENT_VALIDATOR }) - - this.loadCaptionContent() - } - - loadCaptionContent () { - const { captionPath } = this.videoCaption - if (!captionPath) return - - this.videoCaptionService.getCaptionContent({ captionPath }) - .subscribe(res => { - this.form.patchValue({ - captionFileContent: res - }) - this.resetTextarea() - }) - } - - resetTextarea () { - this.textarea.nativeElement.scrollTop = 0 - this.textarea.nativeElement.selectionStart = 0 - this.textarea.nativeElement.selectionEnd = 0 - } - - hide () { - this.openedModal.close() - } - - cancel () { - this.hide() - } - - updateCaption () { - const format = 'vtt' - const languageId = this.videoCaption.language.id - const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId) - - this.captionEdited.emit({ - language: languageObject, - captionfile: new File([ this.form.value['captionFileContent'] ], `${languageId}.${format}`, { - type: 'text/vtt', - lastModified: Date.now() - }), - action: 'UPDATE' - }) - - this.hide() - } -} 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 c5d127b88..aa72a72b1 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 @@ -180,7 +180,38 @@
- + + @if (videoCaption.action) { + + +
+ @switch (videoCaption.action) { + @case ('CREATE') { + Will be created on update + } @case ('UPDATE') { + Will be edited on update + } @case ('REMOVE') { + Will be deleted on update + } + } +
+ + @if (videoCaption.action === 'CREATE' || videoCaption.action === 'UPDATE') { + + } + + + @switch (videoCaption.action) { + @case ('CREATE') { + Cancel create + } @case ('UPDATE') { + Cancel edition + } @case ('REMOVE') { + Cancel deletion + } + } + + } @else {