Improve caption edition

This commit is contained in:
Chocobozzz 2024-08-21 09:53:09 +02:00
parent 6116c1cbad
commit 1bca41366d
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
33 changed files with 743 additions and 285 deletions

View File

@ -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;

View File

@ -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'

View File

@ -0,0 +1,126 @@
<div class="modal-header">
<h4 i18n class="modal-title">Edit caption "{{ videoCaption.language.label }}"</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<div class="row">
<my-embed *ngIf="publishedVideo" #embed class="col-md-12 col-xl-6 mb-3" [video]="publishedVideo" enableAPI="true"></my-embed>
<div [ngClass]="publishedVideo ? 'col-xl-6 col-md-12' : ''">
<div class="d-flex justify-content-between align-items-center mb-3 ms-2">
<my-peertube-checkbox
inputName="raw-edition"
[(ngModel)]="rawEdition" (ngModelChange)="onRawEditionSwitch()"
i18n-labelText labelText="Raw edition"
></my-peertube-checkbox>
<my-button *ngIf="!rawEdition && !segmentToUpdate" i18n-label label="Add a new segment" (click)="addSegmentToEdit()">
</my-button>
</div>
<div [hidden]="!rawEdition" [formGroup]="form">
<textarea
id="captionFileContent"
formControlName="captionFileContent"
i18n-label aria-label="Caption raw content"
class="form-control caption-raw-textarea fs-7"
[ngClass]="{ 'input-error': formErrors['captionFileContent'] }"
#textarea
>
</textarea>
<div *ngIf="formErrors.captionFileContent" class="form-error" role="alert">
{{ formErrors.captionFileContent }}
</div>
</div>
<div class="text-start segments pe-2 ps-2" [hidden]="rawEdition">
<div class="pt-1 pb-1 mb-3" *ngFor="let segment of segments">
@if (segmentToUpdate === segment) {
<div role="form">
<div class="d-flex flex-wrap ">
<div>
<label class="visually-hidden" i18n for="segmentStart">Segment start timestamp</label>
<my-timestamp-input
class="me-1"
inputName="segmentStart" [disableBorder]="false" [maxTimestamp]="publishedVideo.duration * 1000" mask="99:99:99.999"
[(ngModel)]="segment.startMs" [parser]="timestampParser" [formatter]="timestampFormatter"
></my-timestamp-input>
<my-button *ngIf="publishedVideo" icon="clock-arrow-down" i18n-title title="Use video current time as start time" (click)="videoTimeForSegmentStart(segment)">
</my-button>
</div>
<my-global-icon class="d-inline-block ms-2 me-2" iconName="move-right"></my-global-icon>
<div>
<label class="visually-hidden" i18n for="segmentEnd">Segment end timestamp</label>
<my-timestamp-input
class="me-1"
inputName="segmentEnd" [disableBorder]="false" [maxTimestamp]="publishedVideo.duration * 1000" mask="99:99:99.999"
[(ngModel)]="segment.endMs" [parser]="timestampParser" [formatter]="timestampFormatter"
></my-timestamp-input>
<my-button *ngIf="publishedVideo" icon="clock-arrow-down" i18n-title title="Use video current time as end time" (click)="videoTimeForSegmentEnd(segment)">
</my-button>
</div>
</div>
<div class="d-flex mt-2">
<div class="form-group w-100">
<label class="visually-hidden" i18n for="segment-edition">Segment end timestamp</label>
<textarea id="segment-edition" name="segment-edition" class="form-control fs-7" [(ngModel)]="segment.text"></textarea>
<div *ngIf="segmentEditionError" class="form-error" role="alert">{{ segmentEditionError }}</div>
</div>
<div class="d-flex flex-column ms-3">
<my-button i18n-title title="Save" icon="tick" (click)="onEditionSaved(segment)"></my-button>
<my-button class="mt-3" i18n-title title="Revert" icon="undo" (click)="onEditionCanceled(segment)"></my-button>
</div>
</div>
</div>
} @else {
<div class="d-flex">
<div
class="flex-grow-1 segment-text ps-1 pe-1" role="button" tabindex="0" i18n-title title="Jump to this segment"
(keyup.enter)="onSegmentClick($event, segment)" (click)="onSegmentClick($event, segment)"
[ngClass]="getSegmentClasses(segment)"
>
<strong class="segment-start me-2 d-block">{{ segment.startFormatted }} -> {{ segment.endFormatted }}</strong>
<span class="segment-text fs-7" [innerHTML]="segment.text | nl2br"></span>
</div>
<div class="d-flex flex-column ms-3" [ngClass]="{ 'opacity-0': !!segmentToUpdate }">
<my-edit-button i18n-title title="Edit this segment" (click)="updateSegment(segment)"></my-edit-button>
<my-delete-button class="mt-1" i18n-title title="Delete this segment" (click)="deleteSegment(segment)"></my-delete-button>
</div>
</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input
type="button" i18n-value value="Edit this caption" class="peertube-button orange-button"
[disabled]="rawEdition && !form.valid" (click)="updateCaption()"
>
</div>

View File

@ -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;
}

View File

@ -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<VideoCaptionEdit>()
@ViewChild('textarea', { static: true }) textarea: ElementRef
@ViewChild('embed') embed: EmbedComponent
rawEdition = false
segments: Segment[] = []
segmentToUpdate: Segment
segmentEditionError = ''
private segmentToUpdateSave: Segment
activeSegment: Segment
videoCaptionLanguages: VideoConstant<string>[] = []
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<HTMLElement>('.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<HTMLElement>('.segment-' + segment.id)
if (!element) return
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
}

View File

@ -1,36 +0,0 @@
<ng-container [formGroup]="form">
<div class="modal-header">
<h4 i18n class="modal-title">Edit caption</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<label i18n for="captionFileContent">Caption</label>
<textarea
id="captionFileContent"
formControlName="captionFileContent"
class="form-control caption-textarea"
[ngClass]="{ 'input-error': formErrors['captionFileContent'] }"
#textarea
>
</textarea>
<div *ngIf="formErrors.captionFileContent" class="form-error" role="alert">
{{ formErrors.captionFileContent }}
</div>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="cancel()" (key.enter)="cancel()"
>
<input
type="submit" i18n-value value="Edit this caption" class="peertube-button orange-button"
[disabled]="!form.valid" (click)="updateCaption()"
>
</div>
</ng-container>

View File

@ -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<VideoCaptionEdit>()
@ViewChild('textarea', { static: true }) textarea!: ElementRef
videoCaptionLanguages: VideoConstant<string>[] = []
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()
}
}

View File

@ -180,7 +180,38 @@
<div class="form-group" *ngFor="let videoCaption of videoCaptions">
<div class="caption-entry">
<ng-container *ngIf="!videoCaption.action">
@if (videoCaption.action) {
<span class="caption-entry-label">{{ getCaptionLabel(videoCaption) }}</span>
<div i18n class="caption-entry-state caption-entry-state-create">
@switch (videoCaption.action) {
@case ('CREATE') {
Will be created on update
} @case ('UPDATE') {
Will be edited on update
} @case ('REMOVE') {
Will be deleted on update
}
}
</div>
@if (videoCaption.action === 'CREATE' || videoCaption.action === 'UPDATE') {
<my-edit-button class="me-2" i18n-label label="Edit" (click)="openEditCaptionModal(videoCaption)"></my-edit-button>
}
<my-button i18n (click)="deleteCaption(videoCaption)" icon="undo">
@switch (videoCaption.action) {
@case ('CREATE') {
Cancel create
} @case ('UPDATE') {
Cancel edition
} @case ('REMOVE') {
Cancel deletion
}
}
</my-button>
} @else {
<a
i18n-title title="See the subtitle file" class="caption-entry-label" target="_blank" rel="noopener noreferrer"
[href]="videoCaption.captionPath"
@ -188,33 +219,9 @@
<div i18n class="caption-entry-state">Already uploaded on {{ videoCaption.updatedAt | date }} &#10004;</div>
<button i18n class="caption-entry-edit" (click)="openEditCaptionModal(videoCaption)">Edit</button>
<button i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</button>
</ng-container>
<ng-container *ngIf="videoCaption.action === 'CREATE'">
<span class="caption-entry-label">{{ getCaptionLabel(videoCaption) }}</span>
<div i18n class="caption-entry-state caption-entry-state-create">Will be created on update</div>
<button i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel create</button>
</ng-container>
<ng-container *ngIf="videoCaption.action === 'UPDATE'">
<span class="caption-entry-label">{{ getCaptionLabel(videoCaption) }}</span>
<div i18n class="caption-entry-state caption-entry-state-create">Will be edited on update</div>
<button i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel edition</button>
</ng-container>
<ng-container *ngIf="videoCaption.action === 'REMOVE'">
<span class="caption-entry-label">{{ getCaptionLabel(videoCaption) }}</span>
<div i18n class="caption-entry-state caption-entry-state-delete">Will be deleted on update</div>
<button i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</button>
</ng-container>
<my-edit-button i18n-label label="Edit" class="me-2" (click)="openEditCaptionModal(videoCaption)"></my-edit-button>
<my-delete-button label (click)="deleteCaption(videoCaption)"></my-delete-button>
}
</div>
</div>

View File

@ -10,6 +10,10 @@ my-peertube-checkbox {
margin-bottom: 1rem;
}
my-timestamp-input {
width: 85px;
}
.nav-tabs {
margin-bottom: 15px;
}
@ -27,8 +31,9 @@ my-peertube-checkbox {
.caption-entry {
display: flex;
height: 40px;
min-height: 40px;
align-items: center;
flex-wrap: wrap;
a.caption-entry-label {
color: pvar(--mainForegroundColor);
@ -60,15 +65,6 @@ my-peertube-checkbox {
color: $red;
}
}
.caption-entry-edit {
@include peertube-button;
}
.caption-entry-delete {
@include peertube-button;
@include grey-button;
}
}
.no-caption {

View File

@ -12,7 +12,7 @@ import {
booleanAttribute
} from '@angular/core'
import { AbstractControl, FormArray, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'
import { HooksService, PluginService, ServerService } from '@app/core'
import { ConfirmService, HooksService, PluginService, ServerService } from '@app/core'
import { removeElementFromArray } from '@app/helpers'
import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
@ -70,14 +70,16 @@ import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
import { EditButtonComponent } from '../../../shared/shared-main/buttons/edit-button.component'
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component'
import { VideoCaptionAddModalComponent } from './caption/video-caption-add-modal.component'
import { VideoCaptionEditModalContentComponent } from './caption/video-caption-edit-modal-content.component'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { ThumbnailManagerComponent } from './thumbnail-manager/thumbnail-manager.component'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
import { VideoEditType } from './video-edit.type'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
type VideoLanguages = VideoConstant<string> & { group?: string }
type PluginField = {
@ -122,7 +124,9 @@ type PluginField = {
NgbNavOutlet,
VideoCaptionAddModalComponent,
DatePipe,
ThumbnailManagerComponent
ThumbnailManagerComponent,
EditButtonComponent,
ButtonComponent
]
})
export class VideoEditComponent implements OnInit, OnDestroy {
@ -210,7 +214,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
private ngZone: NgZone,
private hooks: HooksService,
private cd: ChangeDetectorRef,
private modalService: NgbModal
private modalService: NgbModal,
private confirmService: ConfirmService
) {
this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone()
this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
@ -398,9 +403,21 @@ export class VideoEditComponent implements OnInit, OnDestroy {
}
openEditCaptionModal (videoCaption: VideoCaptionWithPathEdit) {
const modalRef = this.modalService.open(VideoCaptionEditModalContentComponent, { centered: true, keyboard: false })
const modalRef = this.modalService.open(VideoCaptionEditModalContentComponent, {
centered: true,
size: 'xl',
beforeDismiss: () => {
return this.confirmService.confirm(
$localize`Are you sure you want to close this modal without saving your changes?`,
$localize`Closing caption edition mocal`
)
}
})
modalRef.componentInstance.videoCaption = videoCaption
modalRef.componentInstance.serverConfig = this.serverConfig
modalRef.componentInstance.publishedVideo = this.publishedVideo
modalRef.componentInstance.captionEdited.subscribe(this.onCaptionEdited.bind(this))
}

View File

@ -55,7 +55,7 @@
[ngClass]="getSegmentClasses(segment)"
>
<strong class="segment-start me-2">{{ segment.startFormatted }}</strong>
<span class="segment-text fs-7">{{ segment.text }}</span>
<span class="segment-text fs-7" [innerHTML]="segment.text | nl2br"></span>
</div>
</div>

View File

@ -23,6 +23,7 @@ import debug from 'debug'
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { GlobalIconComponent } from '../../../../shared/shared-icons/global-icon.component'
import { Nl2BrPipe } from '../../../../shared/shared-main/angular/nl2br.pipe'
const debugLogger = debug('peertube:watch:VideoTranscriptionComponent')
@ -48,7 +49,8 @@ type Segment = {
NgFor,
NgbCollapse,
FormsModule,
SelectOptionsComponent
SelectOptionsComponent,
Nl2BrPipe
]
})
export class VideoTranscriptionComponent implements OnInit, OnChanges {

View File

@ -3,9 +3,9 @@ import { DatePipe } from '@angular/common'
let datePipe: DatePipe
let intl: Intl.DateTimeFormat
type DateFormat = 'medium' | 'precise'
export type DateFormat = 'medium' | 'precise'
function dateToHuman (localeId: string, date: Date, format: 'medium' | 'precise' = 'medium') {
export function dateToHuman (localeId: string, date: Date, format: 'medium' | 'precise' = 'medium') {
if (!datePipe) {
datePipe = new DatePipe(localeId)
}
@ -26,7 +26,7 @@ function dateToHuman (localeId: string, date: Date, format: 'medium' | 'precise'
if (format === 'precise') return intl.format(date)
}
function durationToString (duration: number) {
export function durationToString (duration: number) {
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
const seconds = duration % 60
@ -39,10 +39,3 @@ function durationToString (duration: number) {
displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
).replace(/^0/, '')
}
export {
type DateFormat,
durationToString,
dateToHuman
}

View File

@ -1,5 +1,5 @@
<p-inputMask
[disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
[ngClass]="{ 'border-disabled': disableBorder }"
mask="99:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
[mask]="mask" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
></p-inputMask>

View File

@ -3,7 +3,7 @@
p-inputmask {
::ng-deep input {
width: 80px;
width: 100%;
text-align: center;
&:focus-within,
@ -25,7 +25,7 @@ p-inputmask {
&:not(.border-disabled) {
::ng-deep input {
@include peertube-input-text(80px);
@include peertube-input-text(100%);
& {
padding: 3px 10px;

View File

@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms'
import { secondsToTime, timeToInt } from '@peertube/peertube-core-utils'
import { NgClass } from '@angular/common'
import { booleanAttribute, ChangeDetectorRef, Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
import { secondsToTime, timeToInt } from '@peertube/peertube-core-utils'
import { InputMaskModule } from 'primeng/inputmask'
@Component({
@ -21,9 +21,15 @@ import { InputMaskModule } from 'primeng/inputmask'
export class TimestampInputComponent implements ControlValueAccessor, OnInit {
@Input() maxTimestamp: number
@Input() timestamp: number
@Input() disabled = false
@Input({ transform: booleanAttribute }) disabled = false
@Input({ transform: booleanAttribute }) disableBorder = true
@Input() inputName: string
@Input() disableBorder = true
@Input() mask = '99:99:99'
@Input() formatter = (timestamp: number) => secondsToTime({ seconds: timestamp, format: 'full', symbol: ':' })
@Input() parser = (timestampString: string) => timeToInt(timestampString)
@Output() inputBlur = new EventEmitter()
@ -39,8 +45,7 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
writeValue (timestamp: number) {
this.timestamp = timestamp
this.timestampString = secondsToTime({ seconds: this.timestamp, format: 'full', symbol: ':' })
this.timestampString = this.formatter(this.timestamp)
}
registerOnChange (fn: (_: any) => void) {
@ -52,7 +57,7 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
}
onModelChange () {
this.timestamp = timeToInt(this.timestampString)
this.timestamp = this.parser(this.timestampString)
this.propagateChange(this.timestamp)
}

View File

@ -21,65 +21,67 @@ const icons = {
'local': require('../../../assets/images/misc/local.svg'),
// feather/lucide icons
'copy': require('../../../assets/images/feather/copy.svg'),
'flag': require('../../../assets/images/feather/flag.svg'),
'playlists': require('../../../assets/images/feather/list.svg'),
'syndication': require('../../../assets/images/feather/syndication.svg'),
'help': require('../../../assets/images/feather/help.svg'),
'alert': require('../../../assets/images/feather/alert.svg'),
'globe': require('../../../assets/images/feather/globe.svg'),
'home': require('../../../assets/images/feather/home.svg'),
'trending': require('../../../assets/images/feather/trending.svg'),
'search': require('../../../assets/images/feather/search.svg'),
'upload': require('../../../assets/images/feather/upload.svg'),
'dislike': require('../../../assets/images/feather/dislike.svg'),
'like': require('../../../assets/images/feather/like.svg'),
'no': require('../../../assets/images/feather/no.svg'),
'cloud-download': require('../../../assets/images/feather/cloud-download.svg'),
'clock': require('../../../assets/images/feather/clock.svg'),
'cog': require('../../../assets/images/feather/cog.svg'),
'delete': require('../../../assets/images/feather/delete.svg'),
'bell': require('../../../assets/images/feather/bell.svg'),
'sign-out': require('../../../assets/images/feather/log-out.svg'),
'sign-in': require('../../../assets/images/feather/log-in.svg'),
'download': require('../../../assets/images/feather/download.svg'),
'ownership-change': require('../../../assets/images/feather/share.svg'),
'share': require('../../../assets/images/feather/share-2.svg'),
'channel': require('../../../assets/images/feather/tv.svg'),
'user': require('../../../assets/images/feather/user.svg'),
'user-x': require('../../../assets/images/feather/user-x.svg'),
'users': require('../../../assets/images/feather/users.svg'),
'user-add': require('../../../assets/images/feather/user-plus.svg'),
'add': require('../../../assets/images/feather/plus-circle.svg'),
'cloud-error': require('../../../assets/images/feather/cloud-off.svg'),
'undo': require('../../../assets/images/feather/corner-up-left.svg'),
'alert': require('../../../assets/images/feather/alert.svg'),
'award': require('../../../assets/images/feather/award.svg'),
'bell': require('../../../assets/images/feather/bell.svg'),
'channel': require('../../../assets/images/feather/tv.svg'),
'chevrons-up': require('../../../assets/images/feather/chevrons-up.svg'),
'circle-tick': require('../../../assets/images/feather/check-circle.svg'),
'clock-arrow-down': require('../../../assets/images/feather/clock-arrow-down.svg'),
'clock': require('../../../assets/images/feather/clock.svg'),
'cloud-download': require('../../../assets/images/feather/cloud-download.svg'),
'cloud-error': require('../../../assets/images/feather/cloud-off.svg'),
'codesandbox': require('../../../assets/images/feather/codesandbox.svg'),
'cog': require('../../../assets/images/feather/cog.svg'),
'columns': require('../../../assets/images/feather/columns.svg'),
'command': require('../../../assets/images/feather/command.svg'),
'copy': require('../../../assets/images/feather/copy.svg'),
'cross': require('../../../assets/images/feather/x.svg'),
'delete': require('../../../assets/images/feather/delete.svg'),
'dislike': require('../../../assets/images/feather/dislike.svg'),
'download': require('../../../assets/images/feather/download.svg'),
'edit': require('../../../assets/images/feather/edit-2.svg'),
'exit-fullscreen': require('../../../assets/images/feather/minimize.svg'),
'external-link': require('../../../assets/images/feather/external-link.svg'),
'eye-close': require('../../../assets/images/feather/eye-off.svg'),
'eye-open': require('../../../assets/images/feather/eye.svg'),
'film': require('../../../assets/images/feather/film.svg'),
'filter': require('../../../assets/images/feather/filter.svg'),
'flag': require('../../../assets/images/feather/flag.svg'),
'fullscreen': require('../../../assets/images/feather/maximize.svg'),
'globe': require('../../../assets/images/feather/globe.svg'),
'go': require('../../../assets/images/feather/arrow-up-right.svg'),
'help': require('../../../assets/images/feather/help.svg'),
'home': require('../../../assets/images/feather/home.svg'),
'like': require('../../../assets/images/feather/like.svg'),
'live': require('../../../assets/images/feather/live.svg'),
'message-circle': require('../../../assets/images/feather/message-circle.svg'),
'more-horizontal': require('../../../assets/images/feather/more-horizontal.svg'),
'more-vertical': require('../../../assets/images/feather/more-vertical.svg'),
'play': require('../../../assets/images/feather/play.svg'),
'move-right': require('../../../assets/images/feather/move-right.svg'),
'no': require('../../../assets/images/feather/no.svg'),
'ownership-change': require('../../../assets/images/feather/share.svg'),
'p2p': require('../../../assets/images/feather/airplay.svg'),
'fullscreen': require('../../../assets/images/feather/maximize.svg'),
'exit-fullscreen': require('../../../assets/images/feather/minimize.svg'),
'film': require('../../../assets/images/feather/film.svg'),
'edit': require('../../../assets/images/feather/edit-2.svg'),
'external-link': require('../../../assets/images/feather/external-link.svg'),
'eye-open': require('../../../assets/images/feather/eye.svg'),
'eye-close': require('../../../assets/images/feather/eye-off.svg'),
'play': require('../../../assets/images/feather/play.svg'),
'playlists': require('../../../assets/images/feather/list.svg'),
'refresh': require('../../../assets/images/feather/refresh-cw.svg'),
'command': require('../../../assets/images/feather/command.svg'),
'go': require('../../../assets/images/feather/arrow-up-right.svg'),
'cross': require('../../../assets/images/feather/x.svg'),
'tick': require('../../../assets/images/feather/check.svg'),
'columns': require('../../../assets/images/feather/columns.svg'),
'live': require('../../../assets/images/feather/live.svg'),
'repeat': require('../../../assets/images/feather/repeat.svg'),
'chevrons-up': require('../../../assets/images/feather/chevrons-up.svg'),
'message-circle': require('../../../assets/images/feather/message-circle.svg'),
'codesandbox': require('../../../assets/images/feather/codesandbox.svg'),
'award': require('../../../assets/images/feather/award.svg'),
'search': require('../../../assets/images/feather/search.svg'),
'share': require('../../../assets/images/feather/share-2.svg'),
'shield': require('../../../assets/images/misc/shield.svg'),
'sign-in': require('../../../assets/images/feather/log-in.svg'),
'sign-out': require('../../../assets/images/feather/log-out.svg'),
'stats': require('../../../assets/images/feather/stats.svg'),
'filter': require('../../../assets/images/feather/filter.svg'),
'shield': require('../../../assets/images/misc/shield.svg')
'syndication': require('../../../assets/images/feather/syndication.svg'),
'tick': require('../../../assets/images/feather/check.svg'),
'trending': require('../../../assets/images/feather/trending.svg'),
'undo': require('../../../assets/images/feather/undo.svg'),
'upload': require('../../../assets/images/feather/upload.svg'),
'user-add': require('../../../assets/images/feather/user-plus.svg'),
'user-x': require('../../../assets/images/feather/user-x.svg'),
'user': require('../../../assets/images/feather/user.svg'),
'users': require('../../../assets/images/feather/users.svg')
}
export type GlobalIconName = keyof typeof icons

View File

@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core'
import { HtmlRendererService } from '@app/core'
@Pipe({
name: 'nl2br',
standalone: true
})
export class Nl2BrPipe implements PipeTransform {
constructor (private htmlRenderer: HtmlRendererService) {
}
transform (value: string): string {
return this.htmlRenderer.convertToBr(value)
}
}

View File

@ -1,6 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
my-timestamp-input {
width: 85px;
}
.information {
margin-bottom: 20px;
}

View File

@ -1,6 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
my-timestamp-input {
width: 85px;
}
my-input-text {
width: 100%;
}

View File

@ -1,6 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
my-timestamp-input {
width: 60px;
}
.header,
.dropdown-item,
.input-container {

View File

@ -5,6 +5,10 @@
$thumbnail-width: 130px;
$thumbnail-height: 72px;
my-timestamp-input {
width: 85px;
}
my-video-thumbnail {
@include thumbnail-size-component($thumbnail-width, $thumbnail-height);
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-arrow-down"><path d="M12.338 21.994A10 10 0 1 1 21.925 13.227"/><path d="M12 6v6l2 1"/><path d="m14 18 4 4 4-4"/><path d="M18 14v8"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-move-right"><path d="M18 8L22 12L18 16"/><path d="M2 12H22"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@ -74,6 +74,13 @@ export class PeerTubePlayer {
return this.readyPromise
}
/**
* Check if the player is playing the media
*/
async isPlaying () {
return this.sendMessage<undefined, boolean>('isPlaying')
}
/**
* Tell the embed to start/resume playback
*/
@ -122,6 +129,14 @@ export class PeerTubePlayer {
return this.sendMessage<undefined, PeerTubeTextTrack[]>('getCaptions')
}
/**
* Get player current time in seconds.
*
*/
async getCurrentTime () {
return this.sendMessage<undefined, number>('getCurrentTime')
}
/**
* Tell the embed to seek to a specific position (in seconds)
*

View File

@ -44,6 +44,8 @@ export class PeerTubeEmbedApi {
channel.bind('setVideoPassword', (txn, value) => this.embed.setVideoPasswordByAPI(value))
channel.bind('isPlaying', (txn) => !this.player.paused())
channel.bind('play', (txn, params) => {
const p = this.player.play()
if (!p) return
@ -54,7 +56,9 @@ export class PeerTubeEmbedApi {
})
channel.bind('pause', (txn, params) => this.player.pause())
channel.bind('seek', (txn, time) => this.player.currentTime(time))
channel.bind('getCurrentTime', (txn) => this.player.currentTime())
channel.bind('setVolume', (txn, value) => this.player.volume(value))
channel.bind('getVolume', (txn, value) => this.player.volume())

View File

@ -1,17 +1,17 @@
function isToday (d: Date) {
export function isToday (d: Date) {
const today = new Date()
return areDatesEqual(d, today)
}
function isYesterday (d: Date) {
export function isYesterday (d: Date) {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
return areDatesEqual(d, yesterday)
}
function isThisWeek (d: Date) {
export function isThisWeek (d: Date) {
const minDateOfThisWeek = new Date()
minDateOfThisWeek.setHours(0, 0, 0)
@ -25,19 +25,19 @@ function isThisWeek (d: Date) {
return d >= minDateOfThisWeek
}
function isThisMonth (d: Date) {
export function isThisMonth (d: Date) {
const thisMonth = new Date().getMonth()
return d.getMonth() === thisMonth
}
function isLastMonth (d: Date) {
export function isLastMonth (d: Date) {
const now = new Date()
return getDaysDifferences(now, d) <= 30
}
function isLastWeek (d: Date) {
export function isLastWeek (d: Date) {
const now = new Date()
return getDaysDifferences(now, d) <= 7
@ -47,7 +47,7 @@ function isLastWeek (d: Date) {
export const timecodeRegexString = `(\\d+[h:])?(\\d+[m:])?\\d+s?`
function timeToInt (time: number | string) {
export function timeToInt (time: number | string) {
if (!time) return 0
if (typeof time === 'number') return Math.floor(time)
@ -83,7 +83,7 @@ function timeToInt (time: number | string) {
return result
}
function secondsToTime (options: {
export function secondsToTime (options: {
seconds: number
format: 'short' | 'full' | 'locale-string' // default 'short'
symbol?: string
@ -133,7 +133,7 @@ function secondsToTime (options: {
return time
}
function millisecondsToTime (options: {
export function millisecondsToTime (options: {
seconds: number
format: 'short' | 'full' | 'locale-string' // default 'short'
symbol?: string
@ -141,20 +141,19 @@ function millisecondsToTime (options: {
return secondsToTime(typeof options === 'number' ? options / 1000 : { ...options, seconds: options.seconds / 1000 })
}
// ---------------------------------------------------------------------------
export function millisecondsToVttTime (inputArg: number) {
const input = Math.round(inputArg || 0)
export {
isYesterday,
isThisWeek,
isThisMonth,
isToday,
isLastMonth,
isLastWeek,
timeToInt,
secondsToTime,
millisecondsToTime
const hours = String(Math.floor(input / 3600_000)).padStart(2, '0')
const minutes = String(Math.floor((input % 3600_000) / 60_000)).padStart(2, '0')
const seconds = String(Math.floor(input % 60_000 / 1000)).padStart(2, '0')
const ms = String(input % 1000).padStart(3, '0')
return `${hours}:${minutes}:${seconds}.${ms}`
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function areDatesEqual (d1: Date, d2: Date) {

View File

@ -1,4 +1,4 @@
import { millisecondsToTime, secondsToTime } from '@peertube/peertube-core-utils'
import { millisecondsToTime, millisecondsToVttTime, secondsToTime } from '@peertube/peertube-core-utils'
import { expect } from 'chai'
describe('Seconds to time', function () {
@ -27,3 +27,12 @@ describe('Milliseconds to time', function () {
expect(millisecondsToTime(499)).to.equals('')
})
})
describe('Milliseconds to WebVTT time', function () {
it('Should have a valid WebVTT time', function () {
expect(millisecondsToVttTime(1000)).to.equal('00:00:01.000')
expect(millisecondsToVttTime(1001)).to.equal('00:00:01.001')
expect(millisecondsToVttTime(3600_000 * 4 + (60_000 * 45))).to.equal('04:45:00.000')
})
})

View File

@ -207,15 +207,27 @@ This promise is resolved when the video is loaded an the player is ready.
## Embed methods
### `play() : Promise<void>`
### `isPlaying(): Promise<boolean>`
**PeerTube >= 6.3**
Check if the player is playing the media.
### `play(): Promise<void>`
Starts playback, or resumes playback if it is paused.
### `pause() : Promise<void>`
### `pause(): Promise<void>`
Pauses playback.
### `seek(positionInSeconds : number)`
### `getCurrentTime(): Promise<number>`
**PeerTube >= 6.3**
Get player current time in seconds.
### `seek(positionInSeconds : number): Promise<void>`
Seek to the given position, as specified in seconds into the video.