diff options
author | Chocobozzz <me@florianbigard.com> | 2022-02-11 10:51:33 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2022-02-28 10:42:19 +0100 |
commit | c729caf6cc34630877a0e5a1bda1719384cd0c8a (patch) | |
tree | 1d2e13722e518c73d2c9e6f0969615e29d51cf8c /client | |
parent | a24bf4dc659cebb65d887862bf21d7a35e9ec791 (diff) | |
download | PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.gz PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.zst PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.zip |
Add basic video editor support
Diffstat (limited to 'client')
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 | |||
3 | my-embed { | 4 | my-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' | |||
9 | import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' | 9 | import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' |
10 | import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' | 10 | import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' |
11 | import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' | 11 | import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' |
12 | import { VideoChannel, VideoSortField } from '@shared/models' | 12 | import { VideoChannel, VideoSortField, VideoState } from '@shared/models' |
13 | import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' | 13 | import { 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 @@ | |||
1 | export * from './video-editor-edit.component' | ||
2 | export * 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 | |||
21 | h1 { | ||
22 | font-size: 20px; | ||
23 | } | ||
24 | |||
25 | h2 { | ||
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 | |||
63 | my-timestamp-input { | ||
64 | display: block; | ||
65 | } | ||
66 | |||
67 | my-embed { | ||
68 | display: block; | ||
69 | max-width: 500px; | ||
70 | width: 100%; | ||
71 | } | ||
72 | |||
73 | my-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 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { ConfirmService, Notifier, ServerService } from '@app/core' | ||
4 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | ||
5 | import { Video, VideoDetails } from '@app/shared/shared-main' | ||
6 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
7 | import { secondsToTime } from '@shared/core-utils' | ||
8 | import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models' | ||
9 | import { 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 | }) | ||
16 | export 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 | |||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | ||
4 | import { VideoService } from '@app/shared/shared-main' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { catchError } from 'rxjs' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor } from '@app/core' | ||
5 | import { objectToFormData } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models' | ||
8 | |||
9 | @Injectable() | ||
10 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { VideoEditorEditResolver } from './edit' | ||
4 | import { VideoEditorEditComponent } from './edit/video-editor-edit.component' | ||
5 | |||
6 | const 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 | }) | ||
30 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
3 | import { SharedMainModule } from '@app/shared/shared-main' | ||
4 | import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit' | ||
5 | import { VideoEditorService } from './shared' | ||
6 | import { 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 | }) | ||
27 | export 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 | ||
3 | p-inputmask { | 4 | p-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 @@ | |||
1 | import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' |
2 | import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' | 2 | import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core' |
3 | import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' | 3 | import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' |
4 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | 4 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' |
5 | import { VideoCaption } from '@shared/models' | 5 | import { VideoCaption, VideoState } from '@shared/models' |
6 | import { | 6 | import { |
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 | ||