Improve caption edition
This commit is contained in:
parent
6116c1cbad
commit
1bca41366d
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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' })
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -1,4 +0,0 @@
|
|||
.caption-textarea {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 }} ✔</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>
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
my-timestamp-input {
|
||||
width: 85px;
|
||||
}
|
||||
|
||||
.information {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
my-timestamp-input {
|
||||
width: 85px;
|
||||
}
|
||||
|
||||
my-input-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
my-timestamp-input {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.header,
|
||||
.dropdown-item,
|
||||
.input-container {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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)
|
||||
*
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
Loading…
Reference in New Issue