aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-02-11 10:51:33 +0100
committerChocobozzz <chocobozzz@cpy.re>2022-02-28 10:42:19 +0100
commitc729caf6cc34630877a0e5a1bda1719384cd0c8a (patch)
tree1d2e13722e518c73d2c9e6f0969615e29d51cf8c /client
parenta24bf4dc659cebb65d887862bf21d7a35e9ec791 (diff)
downloadPeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.gz
PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.zst
PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.zip
Add basic video editor support
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts3
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts9
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.scss1
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts8
-rw-r--r--client/src/app/+video-editor/edit/index.ts2
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.html88
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.scss76
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.ts202
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.resolver.ts18
-rw-r--r--client/src/app/+video-editor/index.ts1
-rw-r--r--client/src/app/+video-editor/shared/index.ts1
-rw-r--r--client/src/app/+video-editor/shared/video-editor.service.ts28
-rw-r--r--client/src/app/+video-editor/video-editor-routing.module.ts30
-rw-r--r--client/src/app/+video-editor/video-editor.module.ts27
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.html4
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts4
-rw-r--r--client/src/app/app-routing.module.ts6
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.ts2
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts2
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.html3
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.scss14
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts23
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts4
26 files changed, 575 insertions, 9 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index f2eaa3033..e3b6f8305 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
197 resolutions: {} 197 resolutions: {}
198 } 198 }
199 }, 199 },
200 videoEditor: {
201 enabled: null
202 },
200 autoBlacklist: { 203 autoBlacklist: {
201 videos: { 204 videos: {
202 ofUsers: { 205 ofUsers: {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
index 1158f027b..2be855756 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
@@ -192,4 +192,29 @@
192 192
193 </div> 193 </div>
194 </div> 194 </div>
195
196 <div class="form-row mt-2"> <!-- video editor grid -->
197 <div class="form-group col-12 col-lg-4 col-xl-3">
198 <div i18n class="inner-form-title">VIDEO EDITOR</div>
199 <div i18n class="inner-form-description">
200 Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
201 </div>
202 </div>
203
204 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
205
206 <ng-container formGroupName="videoEditor">
207 <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
208 <my-peertube-checkbox
209 inputName="videoEditorEnabled" formControlName="enabled"
210 i18n-labelText labelText="Enable video editor"
211 >
212 <ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
213 <span i18n>⚠️ You need to enable transcoding first to enable video editor</span>
214 </ng-container>
215 </my-peertube-checkbox>
216 </div>
217 </ng-container>
218 </div>
219 </div>
195</ng-container> 220</ng-container>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
index 3397c3dbd..948c10b69 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
@@ -71,6 +71,8 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
71 } 71 }
72 72
73 private checkTranscodingFields () { 73 private checkTranscodingFields () {
74 const transcodingControl = this.form.get('transcoding.enabled')
75 const videoEditorControl = this.form.get('videoEditor.enabled')
74 const hlsControl = this.form.get('transcoding.hls.enabled') 76 const hlsControl = this.form.get('transcoding.hls.enabled')
75 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled') 77 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
76 78
@@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
95 webtorrentControl.enable() 97 webtorrentControl.enable()
96 } 98 }
97 }) 99 })
100
101 transcodingControl.valueChanges
102 .subscribe(newValue => {
103 if (newValue === false) {
104 videoEditorControl.setValue(false)
105 }
106 })
98 } 107 }
99} 108}
diff --git a/client/src/app/+admin/overview/videos/video-list.component.scss b/client/src/app/+admin/overview/videos/video-list.component.scss
index 543cb433c..616b9bc6b 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.scss
+++ b/client/src/app/+admin/overview/videos/video-list.component.scss
@@ -1,5 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3
3my-embed { 4my-embed {
4 display: block; 5 display: block;
5 max-width: 500px; 6 max-width: 500px;
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
index 261e87f99..c998b7c49 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.ts
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' 11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
12import { VideoChannel, VideoSortField } from '@shared/models' 12import { VideoChannel, VideoSortField, VideoState } from '@shared/models'
13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' 13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
14 14
15@Component({ 15@Component({
@@ -205,6 +205,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
205 private buildActions () { 205 private buildActions () {
206 this.videoActions = [ 206 this.videoActions = [
207 { 207 {
208 label: $localize`Editor`,
209 linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
210 isDisplayed: ({ video }) => video.state.id === VideoState.PUBLISHED,
211 iconName: 'film'
212 },
213 {
208 label: $localize`Display live information`, 214 label: $localize`Display live information`,
209 handler: ({ video }) => this.displayLiveInformation(video), 215 handler: ({ video }) => this.displayLiveInformation(video),
210 isDisplayed: ({ video }) => video.isLive, 216 isDisplayed: ({ video }) => video.isLive,
diff --git a/client/src/app/+video-editor/edit/index.ts b/client/src/app/+video-editor/edit/index.ts
new file mode 100644
index 000000000..390ca80fc
--- /dev/null
+++ b/client/src/app/+video-editor/edit/index.ts
@@ -0,0 +1,2 @@
1export * from './video-editor-edit.component'
2export * from './video-editor-edit.resolver'
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.html b/client/src/app/+video-editor/edit/video-editor-edit.component.html
new file mode 100644
index 000000000..d33dfaf18
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.html
@@ -0,0 +1,88 @@
1<div class="margin-content">
2 <h1 class="title-page title-page-single" i18n>Edit {{ video.name }}</h1>
3
4 <div class="columns">
5 <form role="form" [formGroup]="form">
6
7 <div class="section cut" formGroupName="cut">
8 <h2 i18n>CUT VIDEO</h2>
9
10 <div i18n class="description">Set a new start/end.</div>
11
12 <div class="form-group">
13 <label i18n for="cutStart">New start</label>
14 <my-timestamp-input inputName="cutStart" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="start"></my-timestamp-input>
15 </div>
16
17 <div class="form-group">
18 <label i18n for="cutEnd">New end</label>
19 <my-timestamp-input inputName="cutEnd" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="end"></my-timestamp-input>
20 </div>
21 </div>
22
23 <div class="section" formGroupName="add-intro">
24 <h2 i18n>ADD INTRO</h2>
25
26 <div i18n class="description">Concatenate a file at the beginning of the video.</div>
27
28 <div class="form-group">
29 <my-reactive-file
30 formControlName="file" inputName="addIntroFile" i18n-inputLabel inputLabel="Select the intro video file"
31 [extensions]="videoExtensions" [displayFilename]="true"
32 [ngbTooltip]="getIntroOutroTooltip()"
33 ></my-reactive-file>
34 </div>
35 </div>
36
37 <div class="section" formGroupName="add-outro">
38 <h2 i18n>ADD OUTRO</h2>
39
40 <div i18n class="description">Concatenate a file at the end of the video.</div>
41
42 <div class="form-group">
43 <my-reactive-file
44 formControlName="file" inputName="addOutroFile" i18n-inputLabel inputLabel="Select the outro video file"
45 [extensions]="videoExtensions" [displayFilename]="true"
46 [ngbTooltip]="getIntroOutroTooltip()"
47 ></my-reactive-file>
48 </div>
49 </div>
50
51 <div class="section" formGroupName="add-watermark">
52 <h2 i18n>ADD WATERMARK</h2>
53
54 <div i18n class="description">Add a watermark image to the video.</div>
55
56 <div class="form-group">
57 <my-reactive-file
58 formControlName="file" inputName="addWatermarkFile" i18n-inputLabel inputLabel="Select watermark image file"
59 [extensions]="imageExtensions" [displayFilename]="true"
60 [ngbTooltip]="getWatermarkTooltip()"
61 ></my-reactive-file>
62 </div>
63 </div>
64
65 <my-button
66 className="orange-button" i18n-label label="Run video edition" icon="circle-tick"
67 (click)="runEdition()" (keydown.enter)="runEdition()"
68 [disabled]="!form.valid || isRunningEdition || noEdition()"
69 ></my-button>
70 </form>
71
72
73 <div class="information">
74 <div>
75 <label i18n>Video before edition</label>
76 <my-embed [video]="video"></my-embed>
77 </div>
78
79 <div *ngIf="!noEdition()">
80 <label i18n>Edition tasks:</label>
81
82 <ol>
83 <li *ngFor="let task of getTasksSummary()">{{ task }}</li>
84 </ol>
85 </div>
86 </div>
87 </div>
88</div>
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.scss b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
new file mode 100644
index 000000000..43f336f59
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
@@ -0,0 +1,76 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.columns {
5 display: flex;
6
7 .information {
8 width: 100%;
9 margin-left: 50px;
10
11 > div {
12 margin-bottom: 30px;
13 }
14
15 @media screen and (max-width: $small-view) {
16 display: none;
17 }
18 }
19}
20
21h1 {
22 font-size: 20px;
23}
24
25h2 {
26 font-weight: $font-bold;
27 font-size: 16px;
28 color: pvar(--mainColor);
29 background-color: pvar(--mainBackgroundColor);
30 padding: 0 5px;
31 width: fit-content;
32 margin: -8px 0 0;
33}
34
35.section {
36 $min-width: 600px;
37
38 @include padding-left(10px);
39
40 min-width: $min-width;
41
42 margin-bottom: 50px;
43 border: 1px solid $separator-border-color;
44 border-radius: 5px;
45 width: fit-content;
46
47 .form-group,
48 .description {
49 @include margin-left(5px);
50 }
51
52 .description {
53 color: pvar(--greyForegroundColor);
54 margin-top: 5px;
55 margin-bottom: 15px;
56 }
57
58 @media screen and (max-width: $min-width) {
59 min-width: none;
60 }
61}
62
63my-timestamp-input {
64 display: block;
65}
66
67my-embed {
68 display: block;
69 max-width: 500px;
70 width: 100%;
71}
72
73my-reactive-file {
74 display: block;
75 width: fit-content;
76}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.ts b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
new file mode 100644
index 000000000..93d7ffcec
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
@@ -0,0 +1,202 @@
1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { ConfirmService, Notifier, ServerService } from '@app/core'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { Video, VideoDetails } from '@app/shared/shared-main'
6import { LoadingBarService } from '@ngx-loading-bar/core'
7import { secondsToTime } from '@shared/core-utils'
8import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
9import { VideoEditorService } from '../shared'
10
11@Component({
12 selector: 'my-video-editor-edit',
13 templateUrl: './video-editor-edit.component.html',
14 styleUrls: [ './video-editor-edit.component.scss' ]
15})
16export class VideoEditorEditComponent extends FormReactive implements OnInit {
17 isRunningEdition = false
18
19 video: VideoDetails
20
21 constructor (
22 protected formValidatorService: FormValidatorService,
23 private serverService: ServerService,
24 private notifier: Notifier,
25 private router: Router,
26 private route: ActivatedRoute,
27 private videoEditorService: VideoEditorService,
28 private loadingBar: LoadingBarService,
29 private confirmService: ConfirmService
30 ) {
31 super()
32 }
33
34 ngOnInit () {
35 this.video = this.route.snapshot.data.video
36
37 const defaultValues = {
38 cut: {
39 start: 0,
40 end: this.video.duration
41 }
42 }
43
44 this.buildForm({
45 cut: {
46 start: null,
47 end: null
48 },
49 'add-intro': {
50 file: null
51 },
52 'add-outro': {
53 file: null
54 },
55 'add-watermark': {
56 file: null
57 }
58 }, defaultValues)
59 }
60
61 get videoExtensions () {
62 return this.serverService.getHTMLConfig().video.file.extensions
63 }
64
65 get imageExtensions () {
66 return this.serverService.getHTMLConfig().video.image.extensions
67 }
68
69 async runEdition () {
70 if (this.isRunningEdition) return
71
72 const title = $localize`Are you sure you want to edit "${this.video.name}"?`
73 const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
74
75 // eslint-disable-next-line max-len
76 const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
77 $localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
78
79 if (await this.confirmService.confirm(confirmHTML, title) !== true) return
80
81 this.isRunningEdition = true
82
83 const tasks = this.buildTasks()
84
85 this.loadingBar.useRef().start()
86
87 return this.videoEditorService.editVideo(this.video.uuid, tasks)
88 .subscribe({
89 next: () => {
90 this.notifier.success($localize`Video updated.`)
91 this.router.navigateByUrl(Video.buildWatchUrl(this.video))
92 },
93
94 error: err => {
95 this.loadingBar.useRef().complete()
96 this.isRunningEdition = false
97 this.notifier.error(err.message)
98 console.error(err)
99 }
100 })
101 }
102
103 getIntroOutroTooltip () {
104 return $localize`(extensions: ${this.videoExtensions.join(', ')})`
105 }
106
107 getWatermarkTooltip () {
108 return $localize`(extensions: ${this.imageExtensions.join(', ')})`
109 }
110
111 noEdition () {
112 return this.buildTasks().length === 0
113 }
114
115 getTasksSummary () {
116 const tasks = this.buildTasks()
117
118 return tasks.map(t => {
119 if (t.name === 'add-intro') {
120 return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
121 }
122
123 if (t.name === 'add-outro') {
124 return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
125 }
126
127 if (t.name === 'add-watermark') {
128 return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
129 }
130
131 if (t.name === 'cut') {
132 const { start, end } = t.options
133
134 if (start !== undefined && end !== undefined) {
135 return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
136 }
137
138 if (start !== undefined) {
139 return $localize`Video will begin at ${secondsToTime(start)}`
140 }
141
142 if (end !== undefined) {
143 return $localize`Video will stop at ${secondsToTime(end)}`
144 }
145 }
146
147 return ''
148 })
149 }
150
151 private getFilename (obj: any) {
152 return obj.name
153 }
154
155 private buildTasks () {
156 const tasks: VideoEditorTask[] = []
157 const value = this.form.value
158
159 const cut = value['cut']
160 if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
161
162 const options: VideoEditorTaskCut['options'] = {}
163 if (cut['start'] !== 0) options.start = cut['start']
164 if (cut['end'] !== this.video.duration) options.end = cut['end']
165
166 tasks.push({
167 name: 'cut',
168 options
169 })
170 }
171
172 if (value['add-intro']?.['file']) {
173 tasks.push({
174 name: 'add-intro',
175 options: {
176 file: value['add-intro']['file']
177 }
178 })
179 }
180
181 if (value['add-outro']?.['file']) {
182 tasks.push({
183 name: 'add-outro',
184 options: {
185 file: value['add-outro']['file']
186 }
187 })
188 }
189
190 if (value['add-watermark']?.['file']) {
191 tasks.push({
192 name: 'add-watermark',
193 options: {
194 file: value['add-watermark']['file']
195 }
196 })
197 }
198
199 return tasks
200 }
201
202}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
new file mode 100644
index 000000000..7b95ae834
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
@@ -0,0 +1,18 @@
1
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
4import { VideoService } from '@app/shared/shared-main'
5
6@Injectable()
7export class VideoEditorEditResolver implements Resolve<any> {
8 constructor (
9 private videoService: VideoService
10 ) {
11 }
12
13 resolve (route: ActivatedRouteSnapshot) {
14 const videoId: string = route.params['videoId']
15
16 return this.videoService.getVideo({ videoId })
17 }
18}
diff --git a/client/src/app/+video-editor/index.ts b/client/src/app/+video-editor/index.ts
new file mode 100644
index 000000000..5a9e9fdd0
--- /dev/null
+++ b/client/src/app/+video-editor/index.ts
@@ -0,0 +1 @@
export * from './video-editor.module'
diff --git a/client/src/app/+video-editor/shared/index.ts b/client/src/app/+video-editor/shared/index.ts
new file mode 100644
index 000000000..eaf88b6f4
--- /dev/null
+++ b/client/src/app/+video-editor/shared/index.ts
@@ -0,0 +1 @@
export * from './video-editor.service'
diff --git a/client/src/app/+video-editor/shared/video-editor.service.ts b/client/src/app/+video-editor/shared/video-editor.service.ts
new file mode 100644
index 000000000..5b7053039
--- /dev/null
+++ b/client/src/app/+video-editor/shared/video-editor.service.ts
@@ -0,0 +1,28 @@
1import { catchError } from 'rxjs'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core'
5import { objectToFormData } from '@app/helpers'
6import { VideoService } from '@app/shared/shared-main'
7import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models'
8
9@Injectable()
10export class VideoEditorService {
11
12 constructor (
13 private authHttp: HttpClient,
14 private restExtractor: RestExtractor
15 ) {}
16
17 editVideo (videoId: number | string, tasks: VideoEditorTask[]) {
18 const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit'
19 const body: VideoEditorCreateEdition = {
20 tasks
21 }
22
23 const data = objectToFormData(body)
24
25 return this.authHttp.post(url, data)
26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 }
28}
diff --git a/client/src/app/+video-editor/video-editor-routing.module.ts b/client/src/app/+video-editor/video-editor-routing.module.ts
new file mode 100644
index 000000000..9f37a0dae
--- /dev/null
+++ b/client/src/app/+video-editor/video-editor-routing.module.ts
@@ -0,0 +1,30 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { VideoEditorEditResolver } from './edit'
4import { VideoEditorEditComponent } from './edit/video-editor-edit.component'
5
6const videoEditorRoutes: Routes = [
7 {
8 path: '',
9 children: [
10 {
11 path: 'edit/:videoId',
12 component: VideoEditorEditComponent,
13 data: {
14 meta: {
15 title: $localize`Edit video`
16 }
17 },
18 resolve: {
19 video: VideoEditorEditResolver
20 }
21 }
22 ]
23 }
24]
25
26@NgModule({
27 imports: [ RouterModule.forChild(videoEditorRoutes) ],
28 exports: [ RouterModule ]
29})
30export class VideoEditorRoutingModule {}
diff --git a/client/src/app/+video-editor/video-editor.module.ts b/client/src/app/+video-editor/video-editor.module.ts
new file mode 100644
index 000000000..7bbebc17b
--- /dev/null
+++ b/client/src/app/+video-editor/video-editor.module.ts
@@ -0,0 +1,27 @@
1import { NgModule } from '@angular/core'
2import { SharedFormModule } from '@app/shared/shared-forms'
3import { SharedMainModule } from '@app/shared/shared-main'
4import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit'
5import { VideoEditorService } from './shared'
6import { VideoEditorRoutingModule } from './video-editor-routing.module'
7
8@NgModule({
9 imports: [
10 VideoEditorRoutingModule,
11
12 SharedMainModule,
13 SharedFormModule
14 ],
15
16 declarations: [
17 VideoEditorEditComponent
18 ],
19
20 exports: [],
21
22 providers: [
23 VideoEditorService,
24 VideoEditorEditResolver
25 ]
26})
27export class VideoEditorModule { }
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index e59238ffe..6e8a64f46 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
35 playlist: false, 35 playlist: false,
36 download: true, 36 download: true,
37 update: true, 37 update: true,
38 editor: true,
38 blacklist: true, 39 blacklist: true,
39 delete: true, 40 delete: true,
40 report: true, 41 report: true,
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
index 0c4d46714..c6ffb1abd 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
@@ -14,6 +14,10 @@
14 The video is being transcoded, it may not work properly. 14 The video is being transcoded, it may not work properly.
15</div> 15</div>
16 16
17<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
18 The video is being edited, it may not work properly.
19</div>
20
17<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()"> 21<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
18 The video is being moved to an external server, it may not work properly. 22 The video is being moved to an external server, it may not work properly.
19</div> 23</div>
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
index a3d3fa6fb..79b56705f 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
@@ -14,6 +14,10 @@ export class VideoAlertComponent {
14 return this.video && this.video.state.id === VideoState.TO_TRANSCODE 14 return this.video && this.video.state.id === VideoState.TO_TRANSCODE
15 } 15 }
16 16
17 isVideoToEdit () {
18 return this.video && this.video.state.id === VideoState.TO_EDIT
19 }
20
17 isVideoTranscodingFailed () { 21 isVideoTranscodingFailed () {
18 return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED 22 return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
19 } 23 }
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index b5afc9c92..cd499845b 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -143,6 +143,12 @@ const routes: Routes = [
143 canActivateChild: [ MetaGuard ] 143 canActivateChild: [ MetaGuard ]
144 }, 144 },
145 145
146 {
147 path: 'video-editor',
148 loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
149 canActivateChild: [ MetaGuard ]
150 },
151
146 // Matches /@:actorName 152 // Matches /@:actorName
147 { 153 {
148 matcher: (url): UrlMatchResult => { 154 matcher: (url): UrlMatchResult => {
diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts
index 07a12c6f6..6b3a6c773 100644
--- a/client/src/app/shared/shared-forms/form-reactive.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.ts
@@ -24,7 +24,7 @@ export abstract class FormReactive {
24 this.formErrors = formErrors 24 this.formErrors = formErrors
25 this.validationMessages = validationMessages 25 this.validationMessages = validationMessages
26 26
27 this.form.statusChanges.subscribe(async status => { 27 this.form.statusChanges.subscribe(async () => {
28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed 28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
29 await this.waitPendingCheck() 29 await this.waitPendingCheck()
30 30
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index 0fe50ac9b..f67d5bb33 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -30,7 +30,7 @@ export class FormValidatorService {
30 30
31 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } 31 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
32 32
33 const defaultValue = defaultValues[name] || '' 33 const defaultValue = defaultValues[name] ?? ''
34 34
35 if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ] 35 if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
36 else group[name] = [ defaultValue ] 36 else group[name] = [ defaultValue ]
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html
index c57a4b32c..c89a7b019 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.html
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.html
@@ -1,4 +1,5 @@
1<p-inputMask 1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()" 2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" 3 [ngClass]="{ 'border-disabled': disableBorder }"
4 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
4></p-inputMask> 5></p-inputMask>
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss
index d2358c027..27d6fa173 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.scss
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss
@@ -1,10 +1,10 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *;
2 3
3p-inputmask { 4p-inputmask {
4 ::ng-deep input { 5 ::ng-deep input {
5 width: 80px; 6 width: 80px;
6 font-size: 15px; 7 font-size: 15px;
7 border: 0;
8 8
9 &:focus-within, 9 &:focus-within,
10 &:focus { 10 &:focus {
@@ -16,4 +16,16 @@ p-inputmask {
16 opacity: 0.5; 16 opacity: 0.5;
17 } 17 }
18 } 18 }
19
20 &.border-disabled {
21 ::ng-deep input {
22 border: 0;
23 }
24 }
25
26 &:not(.border-disabled) {
27 ::ng-deep input {
28 @include peertube-input-text(80px);
29 }
30 }
19} 31}
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts
index 3fc705905..79ca63673 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.ts
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts
@@ -18,6 +18,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
18 @Input() maxTimestamp: number 18 @Input() maxTimestamp: number
19 @Input() timestamp: number 19 @Input() timestamp: number
20 @Input() disabled = false 20 @Input() disabled = false
21 @Input() inputName: string
22 @Input() disableBorder = true
21 23
22 @Output() inputBlur = new EventEmitter() 24 @Output() inputBlur = new EventEmitter()
23 25
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index c2a318285..abbfc63f8 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -1,8 +1,8 @@
1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' 2import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' 3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
5import { VideoCaption } from '@shared/models' 5import { VideoCaption, VideoState } from '@shared/models'
6import { 6import {
7 Actor, 7 Actor,
8 DropdownAction, 8 DropdownAction,
@@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
29 liveInfo?: boolean 29 liveInfo?: boolean
30 removeFiles?: boolean 30 removeFiles?: boolean
31 transcoding?: boolean 31 transcoding?: boolean
32 editor?: boolean
32} 33}
33 34
34@Component({ 35@Component({
@@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
59 mute: true, 60 mute: true,
60 liveInfo: false, 61 liveInfo: false,
61 removeFiles: false, 62 removeFiles: false,
62 transcoding: false 63 transcoding: false,
64 editor: true
63 } 65 }
64 @Input() placement = 'left' 66 @Input() placement = 'left'
65 67
@@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
89 private videoBlocklistService: VideoBlockService, 91 private videoBlocklistService: VideoBlockService,
90 private screenService: ScreenService, 92 private screenService: ScreenService,
91 private videoService: VideoService, 93 private videoService: VideoService,
92 private redundancyService: RedundancyService 94 private redundancyService: RedundancyService,
95 private serverService: ServerService
93 ) { } 96 ) { }
94 97
95 get user () { 98 get user () {
@@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
149 return this.video.isUpdatableBy(this.user) 152 return this.video.isUpdatableBy(this.user)
150 } 153 }
151 154
155 isVideoEditable () {
156 return this.serverService.getHTMLConfig().videoEditor.enabled &&
157 this.video.state?.id === VideoState.PUBLISHED &&
158 this.video.isUpdatableBy(this.user)
159 }
160
152 isVideoRemovable () { 161 isVideoRemovable () {
153 return this.video.isRemovableBy(this.user) 162 return this.video.isRemovableBy(this.user)
154 } 163 }
@@ -330,6 +339,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
330 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable() 339 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
331 }, 340 },
332 { 341 {
342 label: $localize`Editor`,
343 linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
344 iconName: 'film',
345 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.editor && this.isVideoEditable()
346 },
347 {
333 label: $localize`Block`, 348 label: $localize`Block`,
334 handler: () => this.showBlockModal(), 349 handler: () => this.showBlockModal(),
335 iconName: 'no', 350 iconName: 'no',
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 847e401ed..7de9fc8e2 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -195,6 +195,10 @@ export class VideoMiniatureComponent implements OnInit {
195 return $localize`To import` 195 return $localize`To import`
196 } 196 }
197 197
198 if (video.state.id === VideoState.TO_EDIT) {
199 return $localize`To edit`
200 }
201
198 return '' 202 return ''
199 } 203 }
200 204