aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app')
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts12
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html1
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html16
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts62
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html4
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts17
-rw-r--r--client/src/app/+my-account/my-account.module.ts6
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.html4
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.scss8
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.ts61
-rw-r--r--client/src/app/shared/images/global-icon.component.html0
-rw-r--r--client/src/app/shared/images/global-icon.component.ts3
-rw-r--r--client/src/app/shared/shared.module.ts20
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.ts2
-rw-r--r--client/src/app/shared/user-subscription/user-subscription.service.ts6
-rw-r--r--client/src/app/shared/users/user-notifications.component.scss2
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.html74
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.scss98
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.ts195
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.html6
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.scss11
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.ts8
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.model.ts5
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.service.ts77
-rw-r--r--client/src/app/shared/video/video.service.ts19
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html12
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.scss5
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts11
-rw-r--r--client/src/app/videos/+video-watch/video-watch-routing.module.ts11
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html20
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss11
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts14
-rw-r--r--client/src/app/videos/videos-routing.module.ts6
35 files changed, 756 insertions, 55 deletions
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index 0193afff7..3f921b13f 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -22,6 +22,9 @@ import {
22import { 22import {
23 MyAccountVideoPlaylistUpdateComponent 23 MyAccountVideoPlaylistUpdateComponent
24} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' 24} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
25import {
26 MyAccountVideoPlaylistElementsComponent
27} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
25 28
26const myAccountRoutes: Routes = [ 29const myAccountRoutes: Routes = [
27 { 30 {
@@ -82,6 +85,15 @@ const myAccountRoutes: Routes = [
82 } 85 }
83 }, 86 },
84 { 87 {
88 path: 'video-playlists/:videoPlaylistId',
89 component: MyAccountVideoPlaylistElementsComponent,
90 data: {
91 meta: {
92 title: 'Playlist elements'
93 }
94 }
95 },
96 {
85 path: 'video-playlists/create', 97 path: 'video-playlists/create',
86 component: MyAccountVideoPlaylistCreateComponent, 98 component: MyAccountVideoPlaylistCreateComponent,
87 data: { 99 data: {
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
index 6feb16ab1..0274f47c5 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
@@ -4,7 +4,7 @@
4.custom-row { 4.custom-row {
5 display: flex; 5 display: flex;
6 align-items: center; 6 align-items: center;
7 border-bottom: 1px solid rgba(0, 0, 0, 0.10); 7 border-bottom: 1px solid $separator-border-color;
8 8
9 &:first-child { 9 &:first-child {
10 font-size: 16px; 10 font-size: 16px;
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
index b76488c78..5d1184218 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -60,5 +60,6 @@
60 </div> 60 </div>
61 </div> 61 </div>
62 </div> 62 </div>
63
63 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> 64 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
64</form> 65</form>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
new file mode 100644
index 000000000..28ea7a857
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
@@ -0,0 +1,16 @@
1<div class="no-results">No videos in this playlist.</div>
2
3<div class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
4 <div *ngFor="let video of videos" class="video">
5 <my-video-thumbnail [video]="video"></my-video-thumbnail>
6
7 <div class="video-info">
8 <div class="position">{{ video.playlistElement.position }}</div>
9
10 <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
11
12 <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
13 <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
14 </div>
15 </div>
16</div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
new file mode 100644
index 000000000..5e6774739
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
@@ -0,0 +1,2 @@
1@import '_variables';
2@import '_mixins';
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
new file mode 100644
index 000000000..8b70a9b1a
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
@@ -0,0 +1,62 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { Notifier } from '@app/core'
3import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm'
5import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
6import { Video } from '@app/shared/video/video.model'
7import { Subscription } from 'rxjs'
8import { ActivatedRoute } from '@angular/router'
9import { VideoService } from '@app/shared/video/video.service'
10
11@Component({
12 selector: 'my-account-video-playlist-elements',
13 templateUrl: './my-account-video-playlist-elements.component.html',
14 styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
15})
16export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
17 videos: Video[] = []
18
19 pagination: ComponentPagination = {
20 currentPage: 1,
21 itemsPerPage: 10,
22 totalItems: null
23 }
24
25 private videoPlaylistId: string | number
26 private paramsSub: Subscription
27
28 constructor (
29 private authService: AuthService,
30 private notifier: Notifier,
31 private confirmService: ConfirmService,
32 private route: ActivatedRoute,
33 private videoService: VideoService
34 ) {}
35
36 ngOnInit () {
37 this.paramsSub = this.route.params.subscribe(routeParams => {
38 this.videoPlaylistId = routeParams[ 'videoPlaylistId' ]
39 this.loadElements()
40 })
41 }
42
43 ngOnDestroy () {
44 if (this.paramsSub) this.paramsSub.unsubscribe()
45 }
46
47 onNearOfBottom () {
48 // Last page
49 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
50
51 this.pagination.currentPage += 1
52 this.loadElements()
53 }
54
55 private loadElements () {
56 this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
57 .subscribe(({ totalVideos, videos }) => {
58 this.videos = this.videos.concat(videos)
59 this.pagination.totalItems = totalVideos
60 })
61 }
62}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
index ab5d9cc5a..7d1bed12a 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
@@ -5,10 +5,10 @@
5 </a> 5 </a>
6</div> 6</div>
7 7
8<div class="video-playlists"> 8<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
9 <div *ngFor="let playlist of videoPlaylists" class="video-playlist"> 9 <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
10 <div class="miniature-wrapper"> 10 <div class="miniature-wrapper">
11 <my-video-playlist-miniature [playlist]="playlist"></my-video-playlist-miniature> 11 <my-video-playlist-miniature [playlist]="playlist" [toManage]="true"></my-video-playlist-miniature>
12 </div> 12 </div>
13 13
14 <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons"> 14 <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
index 761ce90e8..e30656b92 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
@@ -69,17 +69,20 @@ export class MyAccountVideoPlaylistsComponent implements OnInit {
69 return playlist.type.id === VideoPlaylistType.REGULAR 69 return playlist.type.id === VideoPlaylistType.REGULAR
70 } 70 }
71 71
72 private loadVideoPlaylists () { 72 onNearOfBottom () {
73 this.authService.userInformationLoaded
74 .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account)))
75 .subscribe(res => this.videoPlaylists = res.data)
76 }
77
78 private ofNearOfBottom () {
79 // Last page 73 // Last page
80 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return 74 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
81 75
82 this.pagination.currentPage += 1 76 this.pagination.currentPage += 1
83 this.loadVideoPlaylists() 77 this.loadVideoPlaylists()
84 } 78 }
79
80 private loadVideoPlaylists () {
81 this.authService.userInformationLoaded
82 .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt')))
83 .subscribe(res => {
84 this.videoPlaylists = this.videoPlaylists.concat(res.data)
85 this.pagination.totalItems = res.total
86 })
87 }
85} 88}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 3dbce2b92..ba8300111 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -32,6 +32,9 @@ import {
32 MyAccountVideoPlaylistUpdateComponent 32 MyAccountVideoPlaylistUpdateComponent
33} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' 33} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
34import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' 34import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
35import {
36 MyAccountVideoPlaylistElementsComponent
37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
35 38
36@NgModule({ 39@NgModule({
37 imports: [ 40 imports: [
@@ -68,7 +71,8 @@ import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-vi
68 71
69 MyAccountVideoPlaylistCreateComponent, 72 MyAccountVideoPlaylistCreateComponent,
70 MyAccountVideoPlaylistUpdateComponent, 73 MyAccountVideoPlaylistUpdateComponent,
71 MyAccountVideoPlaylistsComponent 74 MyAccountVideoPlaylistsComponent,
75 MyAccountVideoPlaylistElementsComponent
72 ], 76 ],
73 77
74 exports: [ 78 exports: [
diff --git a/client/src/app/shared/forms/timestamp-input.component.html b/client/src/app/shared/forms/timestamp-input.component.html
new file mode 100644
index 000000000..c57a4b32c
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.html
@@ -0,0 +1,4 @@
1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
4></p-inputMask>
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss
new file mode 100644
index 000000000..7115777fd
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.scss
@@ -0,0 +1,8 @@
1p-inputmask {
2 /deep/ input {
3 width: 80px;
4 font-size: 15px;
5
6 border: none;
7 }
8}
diff --git a/client/src/app/shared/forms/timestamp-input.component.ts b/client/src/app/shared/forms/timestamp-input.component.ts
new file mode 100644
index 000000000..8d67a96ac
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.ts
@@ -0,0 +1,61 @@
1import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { secondsToTime, timeToInt } from '../../../assets/player/utils'
4
5@Component({
6 selector: 'my-timestamp-input',
7 styleUrls: [ './timestamp-input.component.scss' ],
8 templateUrl: './timestamp-input.component.html',
9 providers: [
10 {
11 provide: NG_VALUE_ACCESSOR,
12 useExisting: forwardRef(() => TimestampInputComponent),
13 multi: true
14 }
15 ]
16})
17export class TimestampInputComponent implements ControlValueAccessor, OnInit {
18 @Input() maxTimestamp: number
19 @Input() timestamp: number
20 @Input() disabled = false
21
22 timestampString: string
23
24 constructor (private changeDetector: ChangeDetectorRef) {}
25
26 ngOnInit () {
27 this.writeValue(this.timestamp || 0)
28 }
29
30 propagateChange = (_: any) => { /* empty */ }
31
32 writeValue (timestamp: number) {
33 this.timestamp = timestamp
34
35 this.timestampString = secondsToTime(this.timestamp, true, ':')
36 }
37
38 registerOnChange (fn: (_: any) => void) {
39 this.propagateChange = fn
40 }
41
42 registerOnTouched () {
43 // Unused
44 }
45
46 onModelChange () {
47 this.timestamp = timeToInt(this.timestampString)
48
49 this.propagateChange(this.timestamp)
50 }
51
52 onBlur () {
53 if (this.maxTimestamp && this.timestamp > this.maxTimestamp) {
54 this.writeValue(this.maxTimestamp)
55
56 this.changeDetector.detectChanges()
57
58 this.propagateChange(this.timestamp)
59 }
60 }
61}
diff --git a/client/src/app/shared/images/global-icon.component.html b/client/src/app/shared/images/global-icon.component.html
deleted file mode 100644
index e69de29bb..000000000
--- a/client/src/app/shared/images/global-icon.component.html
+++ /dev/null
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index e8ada0324..3fda7ee4d 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -25,7 +25,8 @@ const icons = {
25 'like': require('../../../assets/images/video/like.html'), 25 'like': require('../../../assets/images/video/like.html'),
26 'more': require('../../../assets/images/video/more.html'), 26 'more': require('../../../assets/images/video/more.html'),
27 'share': require('../../../assets/images/video/share.html'), 27 'share': require('../../../assets/images/video/share.html'),
28 'upload': require('../../../assets/images/video/upload.html') 28 'upload': require('../../../assets/images/video/upload.html'),
29 'playlist-add': require('../../../assets/images/video/playlist-add.html')
29} 30}
30 31
31export type GlobalIconName = keyof typeof icons 32export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 60a7bd6e2..1f9eee0b7 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -9,6 +9,7 @@ import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.d
9 9
10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
11import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 11import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
12import { KeyFilterModule } from 'primeng/keyfilter'
12 13
13import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 14import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
14import { ButtonComponent } from './buttons/button.component' 15import { ButtonComponent } from './buttons/button.component'
@@ -49,6 +50,7 @@ import {
49 VideoValidatorsService 50 VideoValidatorsService
50} from '@app/shared/forms' 51} from '@app/shared/forms'
51import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 52import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
53import { InputMaskModule } from 'primeng/inputmask'
52import { ScreenService } from '@app/shared/misc/screen.service' 54import { ScreenService } from '@app/shared/misc/screen.service'
53import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' 55import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
54import { VideoCaptionService } from '@app/shared/video-caption' 56import { VideoCaptionService } from '@app/shared/video-caption'
@@ -74,6 +76,8 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.
74import { ImageUploadComponent } from '@app/shared/images/image-upload.component' 76import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
75import { GlobalIconComponent } from '@app/shared/images/global-icon.component' 77import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
76import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' 78import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
79import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
80import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
77 81
78@NgModule({ 82@NgModule({
79 imports: [ 83 imports: [
@@ -90,6 +94,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
90 NgbTooltipModule, 94 NgbTooltipModule,
91 95
92 PrimeSharedModule, 96 PrimeSharedModule,
97 InputMaskModule,
98 KeyFilterModule,
93 NgPipesModule 99 NgPipesModule
94 ], 100 ],
95 101
@@ -100,11 +106,14 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
100 VideoThumbnailComponent, 106 VideoThumbnailComponent,
101 VideoMiniatureComponent, 107 VideoMiniatureComponent,
102 VideoPlaylistMiniatureComponent, 108 VideoPlaylistMiniatureComponent,
109 VideoAddToPlaylistComponent,
103 110
104 FeedComponent, 111 FeedComponent,
112
105 ButtonComponent, 113 ButtonComponent,
106 DeleteButtonComponent, 114 DeleteButtonComponent,
107 EditButtonComponent, 115 EditButtonComponent,
116
108 ActionDropdownComponent, 117 ActionDropdownComponent,
109 NumberFormatterPipe, 118 NumberFormatterPipe,
110 ObjectLengthPipe, 119 ObjectLengthPipe,
@@ -113,8 +122,11 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
113 InfiniteScrollerDirective, 122 InfiniteScrollerDirective,
114 TextareaAutoResizeDirective, 123 TextareaAutoResizeDirective,
115 HelpComponent, 124 HelpComponent,
125
116 ReactiveFileComponent, 126 ReactiveFileComponent,
117 PeertubeCheckboxComponent, 127 PeertubeCheckboxComponent,
128 TimestampInputComponent,
129
118 SubscribeButtonComponent, 130 SubscribeButtonComponent,
119 RemoteSubscribeComponent, 131 RemoteSubscribeComponent,
120 InstanceFeaturesTableComponent, 132 InstanceFeaturesTableComponent,
@@ -142,6 +154,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
142 NgbTooltipModule, 154 NgbTooltipModule,
143 155
144 PrimeSharedModule, 156 PrimeSharedModule,
157 InputMaskModule,
158 KeyFilterModule,
145 BytesPipe, 159 BytesPipe,
146 KeysPipe, 160 KeysPipe,
147 161
@@ -151,18 +165,24 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
151 VideoThumbnailComponent, 165 VideoThumbnailComponent,
152 VideoMiniatureComponent, 166 VideoMiniatureComponent,
153 VideoPlaylistMiniatureComponent, 167 VideoPlaylistMiniatureComponent,
168 VideoAddToPlaylistComponent,
154 169
155 FeedComponent, 170 FeedComponent,
171
156 ButtonComponent, 172 ButtonComponent,
157 DeleteButtonComponent, 173 DeleteButtonComponent,
158 EditButtonComponent, 174 EditButtonComponent,
175
159 ActionDropdownComponent, 176 ActionDropdownComponent,
160 MarkdownTextareaComponent, 177 MarkdownTextareaComponent,
161 InfiniteScrollerDirective, 178 InfiniteScrollerDirective,
162 TextareaAutoResizeDirective, 179 TextareaAutoResizeDirective,
163 HelpComponent, 180 HelpComponent,
181
164 ReactiveFileComponent, 182 ReactiveFileComponent,
165 PeertubeCheckboxComponent, 183 PeertubeCheckboxComponent,
184 TimestampInputComponent,
185
166 SubscribeButtonComponent, 186 SubscribeButtonComponent,
167 RemoteSubscribeComponent, 187 RemoteSubscribeComponent,
168 InstanceFeaturesTableComponent, 188 InstanceFeaturesTableComponent,
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
index 8f1754c7f..ef470ee44 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.ts
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts
@@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit {
38 38
39 ngOnInit () { 39 ngOnInit () {
40 if (this.isUserLoggedIn()) { 40 if (this.isUserLoggedIn()) {
41 this.userSubscriptionService.isSubscriptionExists(this.uri) 41 this.userSubscriptionService.doesSubscriptionExist(this.uri)
42 .subscribe( 42 .subscribe(
43 res => this.subscribed = res[this.uri], 43 res => this.subscribed = res[this.uri],
44 44
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts
index 3d05f071e..cfd5b100f 100644
--- a/client/src/app/shared/user-subscription/user-subscription.service.ts
+++ b/client/src/app/shared/user-subscription/user-subscription.service.ts
@@ -28,7 +28,7 @@ export class UserSubscriptionService {
28 this.existsObservable = this.existsSubject.pipe( 28 this.existsObservable = this.existsSubject.pipe(
29 bufferTime(500), 29 bufferTime(500),
30 filter(uris => uris.length !== 0), 30 filter(uris => uris.length !== 0),
31 switchMap(uris => this.areSubscriptionExist(uris)), 31 switchMap(uris => this.doSubscriptionsExist(uris)),
32 share() 32 share()
33 ) 33 )
34 } 34 }
@@ -69,13 +69,13 @@ export class UserSubscriptionService {
69 ) 69 )
70 } 70 }
71 71
72 isSubscriptionExists (nameWithHost: string) { 72 doesSubscriptionExist (nameWithHost: string) {
73 this.existsSubject.next(nameWithHost) 73 this.existsSubject.next(nameWithHost)
74 74
75 return this.existsObservable.pipe(first()) 75 return this.existsObservable.pipe(first())
76 } 76 }
77 77
78 private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> { 78 private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> {
79 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' 79 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
80 let params = new HttpParams() 80 let params = new HttpParams()
81 81
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss
index 315d504c9..88f38d9cf 100644
--- a/client/src/app/shared/users/user-notifications.component.scss
+++ b/client/src/app/shared/users/user-notifications.component.scss
@@ -13,7 +13,7 @@
13 align-items: center; 13 align-items: center;
14 font-size: inherit; 14 font-size: inherit;
15 padding: 15px 5px 15px 10px; 15 padding: 15px 5px 15px 10px;
16 border-bottom: 1px solid rgba(0, 0, 0, 0.10); 16 border-bottom: 1px solid $separator-border-color;
17 17
18 &.unread { 18 &.unread {
19 background-color: rgba(0, 0, 0, 0.05); 19 background-color: rgba(0, 0, 0, 0.05);
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
new file mode 100644
index 000000000..ed3cd8dc5
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
@@ -0,0 +1,74 @@
1<div class="header">
2 <div class="first-row">
3 <div i18n class="title">Save to</div>
4
5 <div i18n class="options" (click)="displayOptions = !displayOptions">
6 <my-global-icon iconName="cog"></my-global-icon>
7
8 Options
9 </div>
10 </div>
11
12 <div class="options-row" *ngIf="displayOptions">
13 <div>
14 <my-peertube-checkbox
15 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
16 i18n-labelText labelText="Start at"
17 ></my-peertube-checkbox>
18
19 <my-timestamp-input
20 [timestamp]="timestampOptions.startTimestamp"
21 [maxTimestamp]="video.duration"
22 [disabled]="!timestampOptions.startTimestampEnabled"
23 [(ngModel)]="timestampOptions.startTimestamp"
24 ></my-timestamp-input>
25 </div>
26
27 <div>
28 <my-peertube-checkbox
29 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
30 i18n-labelText labelText="Stop at"
31 ></my-peertube-checkbox>
32
33 <my-timestamp-input
34 [timestamp]="timestampOptions.stopTimestamp"
35 [maxTimestamp]="video.duration"
36 [disabled]="!timestampOptions.stopTimestampEnabled"
37 [(ngModel)]="timestampOptions.stopTimestamp"
38 ></my-timestamp-input>
39 </div>
40 </div>
41</div>
42
43<div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
44 <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist"></my-peertube-checkbox>
45
46 <div class="display-name">
47 {{ playlist.displayName }}
48
49 <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
50 {{ formatTimestamp(playlist) }}
51 </div>
52 </div>
53</div>
54
55<div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
56 <my-global-icon iconName="add"></my-global-icon>
57
58 Create a new playlist
59</div>
60
61<form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
62 <div class="form-group">
63 <label i18n for="display-name">Display name</label>
64 <input
65 type="text" id="display-name"
66 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
67 >
68 <div *ngIf="formErrors['display-name']" class="form-error">
69 {{ formErrors['display-name'] }}
70 </div>
71 </div>
72
73 <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
74</form>
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
new file mode 100644
index 000000000..68dcda1eb
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
@@ -0,0 +1,98 @@
1@import '_variables';
2@import '_mixins';
3
4.header {
5 min-width: 240px;
6 padding: 6px 24px 10px 24px;
7
8 margin-bottom: 10px;
9 border-bottom: 1px solid $separator-border-color;
10
11 .first-row {
12 display: flex;
13 align-items: center;
14
15 .title {
16 font-size: 18px;
17 flex-grow: 1;
18 }
19
20 .options {
21 font-size: 14px;
22 cursor: pointer;
23
24 my-global-icon {
25 @include apply-svg-color(#333);
26
27 width: 16px;
28 height: 16px;
29 }
30 }
31 }
32
33 .options-row {
34 margin-top: 10px;
35
36 > div {
37 display: flex;
38 align-items: center;
39 }
40 }
41}
42
43.dropdown-item {
44 padding: 6px 24px;
45}
46
47.playlist {
48 display: flex;
49 cursor: pointer;
50
51 my-peertube-checkbox {
52 margin-right: 10px;
53 }
54
55 .display-name {
56 display: flex;
57 align-items: flex-end;
58
59 .timestamp-info {
60 font-size: 0.9em;
61 color: $grey-foreground-color;
62 margin-left: 5px;
63 }
64 }
65}
66
67.new-playlist-button,
68.new-playlist-block {
69 padding-top: 10px;
70 margin-top: 10px;
71 border-top: 1px solid $separator-border-color;
72}
73
74.new-playlist-button {
75 cursor: pointer;
76
77 my-global-icon {
78 @include apply-svg-color(#333);
79
80 position: relative;
81 left: -1px;
82 top: -1px;
83 margin-right: 4px;
84 width: 21px;
85 height: 21px;
86 }
87}
88
89input[type=text] {
90 @include peertube-input-text(200px);
91
92 display: block;
93}
94
95input[type=submit] {
96 @include peertube-button;
97 @include orange-button;
98}
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
new file mode 100644
index 000000000..c6fb6dbed
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
@@ -0,0 +1,195 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
3import { AuthService, Notifier } from '@app/core'
4import { forkJoin } from 'rxjs'
5import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
6import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { secondsToTime, timeToInt } from '../../../assets/player/utils'
9
10type PlaylistSummary = {
11 id: number
12 inPlaylist: boolean
13 displayName: string
14
15 startTimestamp?: number
16 stopTimestamp?: number
17}
18
19@Component({
20 selector: 'my-video-add-to-playlist',
21 styleUrls: [ './video-add-to-playlist.component.scss' ],
22 templateUrl: './video-add-to-playlist.component.html'
23})
24export class VideoAddToPlaylistComponent extends FormReactive implements OnInit {
25 @Input() video: Video
26 @Input() currentVideoTimestamp: number
27
28 isNewPlaylistBlockOpened = false
29 videoPlaylists: PlaylistSummary[] = []
30 timestampOptions: {
31 startTimestampEnabled: boolean
32 startTimestamp: number
33 stopTimestampEnabled: boolean
34 stopTimestamp: number
35 }
36 displayOptions = false
37
38 constructor (
39 protected formValidatorService: FormValidatorService,
40 private authService: AuthService,
41 private notifier: Notifier,
42 private i18n: I18n,
43 private videoPlaylistService: VideoPlaylistService,
44 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService
45 ) {
46 super()
47 }
48
49 get user () {
50 return this.authService.getUser()
51 }
52
53 ngOnInit () {
54 this.resetOptions(true)
55
56 this.buildForm({
57 'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
58 })
59
60 forkJoin([
61 this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'),
62 this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id)
63 ])
64 .subscribe(
65 ([ playlistsResult, existResult ]) => {
66 for (const playlist of playlistsResult.data) {
67 const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id)
68
69 this.videoPlaylists.push({
70 id: playlist.id,
71 displayName: playlist.displayName,
72 inPlaylist: !!existingPlaylist,
73 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
74 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
75 })
76 }
77 }
78 )
79 }
80
81 openChange (opened: boolean) {
82 if (opened === false) {
83 this.isNewPlaylistBlockOpened = false
84 this.displayOptions = false
85 }
86 }
87
88 openCreateBlock (event: Event) {
89 event.preventDefault()
90
91 this.isNewPlaylistBlockOpened = true
92 }
93
94 togglePlaylist (event: Event, playlist: PlaylistSummary) {
95 event.preventDefault()
96
97 if (playlist.inPlaylist === true) {
98 this.removeVideoFromPlaylist(playlist)
99 } else {
100 this.addVideoInPlaylist(playlist)
101 }
102
103 playlist.inPlaylist = !playlist.inPlaylist
104 this.resetOptions()
105 }
106
107 createPlaylist () {
108 const displayName = this.form.value[ 'display-name' ]
109
110 const videoPlaylistCreate: VideoPlaylistCreate = {
111 displayName,
112 privacy: VideoPlaylistPrivacy.PRIVATE
113 }
114
115 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
116 res => {
117 this.videoPlaylists.push({
118 id: res.videoPlaylist.id,
119 displayName,
120 inPlaylist: false
121 })
122
123 this.isNewPlaylistBlockOpened = false
124 },
125
126 err => this.notifier.error(err.message)
127 )
128 }
129
130 resetOptions (resetTimestamp = false) {
131 this.displayOptions = false
132
133 this.timestampOptions = {} as any
134 this.timestampOptions.startTimestampEnabled = false
135 this.timestampOptions.stopTimestampEnabled = false
136
137 if (resetTimestamp) {
138 this.timestampOptions.startTimestamp = 0
139 this.timestampOptions.stopTimestamp = this.video.duration
140 }
141 }
142
143 formatTimestamp (playlist: PlaylistSummary) {
144 const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
145 const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
146
147 return `(${start}-${stop})`
148 }
149
150 private removeVideoFromPlaylist (playlist: PlaylistSummary) {
151 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id)
152 .subscribe(
153 () => {
154 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
155
156 playlist.inPlaylist = false
157 },
158
159 err => {
160 this.notifier.error(err.message)
161
162 playlist.inPlaylist = true
163 }
164 )
165 }
166
167 private addVideoInPlaylist (playlist: PlaylistSummary) {
168 const body: VideoPlaylistElementCreate = { videoId: this.video.id }
169
170 if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
171 if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
172
173 this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
174 .subscribe(
175 () => {
176 playlist.inPlaylist = true
177
178 playlist.startTimestamp = body.startTimestamp
179 playlist.stopTimestamp = body.stopTimestamp
180
181 const message = body.startTimestamp || body.stopTimestamp
182 ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
183 : this.i18n('Video added in {{n}}', { n: playlist.displayName })
184
185 this.notifier.success(message)
186 },
187
188 err => {
189 this.notifier.error(err.message)
190
191 playlist.inPlaylist = false
192 }
193 )
194 }
195}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
index 1a39f5fe5..a136f9233 100644
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
@@ -1,6 +1,6 @@
1<div class="miniature"> 1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
2 <a 2 <a
3 [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName" 3 [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName"
4 class="miniature-thumbnail" 4 class="miniature-thumbnail"
5 > 5 >
6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> 6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
@@ -15,7 +15,7 @@
15 </a> 15 </a>
16 16
17 <div class="miniature-bottom"> 17 <div class="miniature-bottom">
18 <a tabindex="-1" class="miniature-name" [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName"> 18 <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName">
19 {{ playlist.displayName }} 19 {{ playlist.displayName }}
20 </a> 20 </a>
21 </div> 21 </div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
index a47206577..f8cd47f73 100644
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
@@ -5,6 +5,17 @@
5.miniature { 5.miniature {
6 display: inline-block; 6 display: inline-block;
7 7
8 &.no-videos:not(.to-manage){
9 a {
10 cursor: default !important;
11 }
12 }
13
14 &.to-manage .play-overlay,
15 &.no-videos {
16 display: none;
17 }
18
8 .miniature-thumbnail { 19 .miniature-thumbnail {
9 @include miniature-thumbnail; 20 @include miniature-thumbnail;
10 21
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
index b3bba7c87..cb5803400 100644
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
@@ -8,4 +8,12 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
8}) 8})
9export class VideoPlaylistMiniatureComponent { 9export class VideoPlaylistMiniatureComponent {
10 @Input() playlist: VideoPlaylist 10 @Input() playlist: VideoPlaylist
11 @Input() toManage = false
12
13 getPlaylistUrl () {
14 if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
15 if (this.playlist.videosLength === 0) return null
16
17 return [ '/videos/watch/playlist', this.playlist.uuid ]
18 }
11} 19}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
index 9d0b02789..ec8013e89 100644
--- a/client/src/app/shared/video-playlist/video-playlist.model.ts
+++ b/client/src/app/shared/video-playlist/video-playlist.model.ts
@@ -46,6 +46,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
46 this.isLocal = hash.isLocal 46 this.isLocal = hash.isLocal
47 47
48 this.displayName = hash.displayName 48 this.displayName = hash.displayName
49
49 this.description = hash.description 50 this.description = hash.description
50 this.privacy = hash.privacy 51 this.privacy = hash.privacy
51 52
@@ -70,5 +71,9 @@ export class VideoPlaylist implements ServerVideoPlaylist {
70 } 71 }
71 72
72 this.privacy.label = peertubeTranslate(this.privacy.label, translations) 73 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
74
75 if (this.type.id === VideoPlaylistType.WATCH_LATER) {
76 this.displayName = peertubeTranslate(this.displayName, translations)
77 }
73 } 78 }
74} 79}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
index 8b66e122c..f7b37f83a 100644
--- a/client/src/app/shared/video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/video-playlist/video-playlist.service.ts
@@ -1,9 +1,9 @@
1import { catchError, map, switchMap } from 'rxjs/operators' 1import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators'
2import { Injectable } from '@angular/core' 2import { Injectable } from '@angular/core'
3import { Observable } from 'rxjs' 3import { Observable, ReplaySubject, Subject } from 'rxjs'
4import { RestExtractor } from '../rest/rest-extractor.service' 4import { RestExtractor } from '../rest/rest-extractor.service'
5import { HttpClient } from '@angular/common/http' 5import { HttpClient, HttpParams } from '@angular/common/http'
6import { ResultList } from '../../../../../shared' 6import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
7import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
8import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' 8import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
@@ -15,16 +15,31 @@ import { ServerService } from '@app/core'
15import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 15import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
16import { AccountService } from '@app/shared/account/account.service' 16import { AccountService } from '@app/shared/account/account.service'
17import { Account } from '@app/shared/account/account.model' 17import { Account } from '@app/shared/account/account.model'
18import { RestService } from '@app/shared/rest'
19import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
18 20
19@Injectable() 21@Injectable()
20export class VideoPlaylistService { 22export class VideoPlaylistService {
21 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' 23 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
24 static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
25
26 // Use a replay subject because we "next" a value before subscribing
27 private videoExistsInPlaylistSubject: Subject<number> = new ReplaySubject(1)
28 private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist>
22 29
23 constructor ( 30 constructor (
24 private authHttp: HttpClient, 31 private authHttp: HttpClient,
25 private serverService: ServerService, 32 private serverService: ServerService,
26 private restExtractor: RestExtractor 33 private restExtractor: RestExtractor,
27 ) { } 34 private restService: RestService
35 ) {
36 this.videoExistsInPlaylistObservable = this.videoExistsInPlaylistSubject.pipe(
37 bufferTime(500),
38 filter(videoIds => videoIds.length !== 0),
39 switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
40 share()
41 )
42 }
28 43
29 listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> { 44 listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
30 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' 45 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
@@ -36,10 +51,13 @@ export class VideoPlaylistService {
36 ) 51 )
37 } 52 }
38 53
39 listAccountPlaylists (account: Account): Observable<ResultList<VideoPlaylist>> { 54 listAccountPlaylists (account: Account, sort: string): Observable<ResultList<VideoPlaylist>> {
40 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' 55 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
41 56
42 return this.authHttp.get<ResultList<VideoPlaylist>>(url) 57 let params = new HttpParams()
58 params = this.restService.addRestGetParams(params, undefined, sort)
59
60 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
43 .pipe( 61 .pipe(
44 switchMap(res => this.extractPlaylists(res)), 62 switchMap(res => this.extractPlaylists(res)),
45 catchError(err => this.restExtractor.handleError(err)) 63 catchError(err => this.restExtractor.handleError(err))
@@ -59,9 +77,8 @@ export class VideoPlaylistService {
59 createVideoPlaylist (body: VideoPlaylistCreate) { 77 createVideoPlaylist (body: VideoPlaylistCreate) {
60 const data = objectToFormData(body) 78 const data = objectToFormData(body)
61 79
62 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) 80 return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
63 .pipe( 81 .pipe(
64 map(this.restExtractor.extractDataBool),
65 catchError(err => this.restExtractor.handleError(err)) 82 catchError(err => this.restExtractor.handleError(err))
66 ) 83 )
67 } 84 }
@@ -84,6 +101,36 @@ export class VideoPlaylistService {
84 ) 101 )
85 } 102 }
86 103
104 addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
105 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos', body)
106 .pipe(
107 map(this.restExtractor.extractDataBool),
108 catchError(err => this.restExtractor.handleError(err))
109 )
110 }
111
112 updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) {
113 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body)
114 .pipe(
115 map(this.restExtractor.extractDataBool),
116 catchError(err => this.restExtractor.handleError(err))
117 )
118 }
119
120 removeVideoFromPlaylist (playlistId: number, videoId: number) {
121 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId)
122 .pipe(
123 map(this.restExtractor.extractDataBool),
124 catchError(err => this.restExtractor.handleError(err))
125 )
126 }
127
128 doesVideoExistInPlaylist (videoId: number) {
129 this.videoExistsInPlaylistSubject.next(videoId)
130
131 return this.videoExistsInPlaylistObservable.pipe(first())
132 }
133
87 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) { 134 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
88 return this.serverService.localeObservable 135 return this.serverService.localeObservable
89 .pipe( 136 .pipe(
@@ -105,4 +152,14 @@ export class VideoPlaylistService {
105 return this.serverService.localeObservable 152 return this.serverService.localeObservable
106 .pipe(map(translations => new VideoPlaylist(playlist, translations))) 153 .pipe(map(translations => new VideoPlaylist(playlist, translations)))
107 } 154 }
155
156 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> {
157 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
158 let params = new HttpParams()
159
160 params = this.restService.addObjectParams(params, { videoIds })
161
162 return this.authHttp.get<VideoExistInPlaylist>(url, { params })
163 .pipe(catchError(err => this.restExtractor.handleError(err)))
164 }
108} 165}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 960846e21..ef489648c 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -31,6 +31,8 @@ import { ServerService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
35import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
34 36
35export interface VideosProvider { 37export interface VideosProvider {
36 getVideos ( 38 getVideos (
@@ -170,6 +172,23 @@ export class VideoService implements VideosProvider {
170 ) 172 )
171 } 173 }
172 174
175 getPlaylistVideos (
176 videoPlaylistId: number | string,
177 videoPagination: ComponentPagination
178 ): Observable<{ videos: Video[], totalVideos: number }> {
179 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
180
181 let params = new HttpParams()
182 params = this.restService.addRestGetParams(params, pagination)
183
184 return this.authHttp
185 .get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
186 .pipe(
187 switchMap(res => this.extractVideos(res)),
188 catchError(err => this.restExtractor.handleError(err))
189 )
190 }
191
173 getUserSubscriptionVideos ( 192 getUserSubscriptionVideos (
174 videoPagination: ComponentPagination, 193 videoPagination: ComponentPagination,
175 sort: VideoSortField 194 sort: VideoSortField
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
index 9f3c37fe8..955b2b80c 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.html
@@ -6,11 +6,19 @@
6 6
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div *ngIf="currentVideoTimestampString" class="start-at"> 9 <div class="start-at">
10 <my-peertube-checkbox 10 <my-peertube-checkbox
11 inputName="startAt" [(ngModel)]="startAtCheckbox" 11 inputName="startAt" [(ngModel)]="startAtCheckbox"
12 i18n-labelText [labelText]="getStartCheckboxLabel()" 12 i18n-labelText labelText="Start at"
13 ></my-peertube-checkbox> 13 ></my-peertube-checkbox>
14
15 <my-timestamp-input
16 [timestamp]="currentVideoTimestamp"
17 [maxTimestamp]="video.duration"
18 [disabled]="!startAtCheckbox"
19 [(ngModel)]="currentVideoTimestamp"
20 >
21 </my-timestamp-input>
14 </div> 22 </div>
15 23
16 <div class="form-group"> 24 <div class="form-group">
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
index 4937506b9..472a45920 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss
@@ -13,4 +13,9 @@
13 display: flex; 13 display: flex;
14 justify-content: center; 14 justify-content: center;
15 margin-top: 10px; 15 margin-top: 10px;
16 align-items: center;
17
18 my-timestamp-input {
19 margin-left: 10px;
20 }
16} 21}
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
index c6205e355..6565d7f88 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts
@@ -16,10 +16,8 @@ export class VideoShareComponent {
16 16
17 @Input() video: VideoDetails = null 17 @Input() video: VideoDetails = null
18 18
19 currentVideoTimestamp: number
19 startAtCheckbox = false 20 startAtCheckbox = false
20 currentVideoTimestampString: string
21
22 private currentVideoTimestamp: number
23 21
24 constructor ( 22 constructor (
25 private modalService: NgbModal, 23 private modalService: NgbModal,
@@ -28,8 +26,7 @@ export class VideoShareComponent {
28 ) { } 26 ) { }
29 27
30 show (currentVideoTimestamp?: number) { 28 show (currentVideoTimestamp?: number) {
31 this.currentVideoTimestamp = Math.floor(currentVideoTimestamp) 29 this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0
32 this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp)
33 30
34 this.modalService.open(this.modal) 31 this.modalService.open(this.modal)
35 } 32 }
@@ -52,10 +49,6 @@ export class VideoShareComponent {
52 this.notifier.success(this.i18n('Copied')) 49 this.notifier.success(this.i18n('Copied'))
53 } 50 }
54 51
55 getStartCheckboxLabel () {
56 return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString })
57 }
58
59 private getVideoTimestampIfEnabled () { 52 private getVideoTimestampIfEnabled () {
60 if (this.startAtCheckbox === true) return this.currentVideoTimestamp 53 if (this.startAtCheckbox === true) return this.currentVideoTimestamp
61 54
diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
index bdd4f945e..0d7809044 100644
--- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
@@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component'
7 7
8const videoWatchRoutes: Routes = [ 8const videoWatchRoutes: Routes = [
9 { 9 {
10 path: '', 10 path: 'playlist/:uuid',
11 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ]
13 },
14 {
15 path: ':uuid/comments/:commentId',
16 redirectTo: ':uuid'
17 },
18 {
19 path: ':uuid',
11 component: VideoWatchComponent, 20 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ] 21 canActivate: [ MetaGuard ]
13 } 22 }
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index fffcc1275..615b88bd6 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -65,17 +65,31 @@
65 <my-global-icon iconName="dislike"></my-global-icon> 65 <my-global-icon iconName="dislike"></my-global-icon>
66 </div> 66 </div>
67 67
68 <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> 68 <div *ngIf="video.support" (click)="showSupportModal()" class="action-button">
69 <my-global-icon iconName="heart"></my-global-icon> 69 <my-global-icon iconName="heart"></my-global-icon>
70 <span class="icon-text" i18n>Support</span> 70 <span class="icon-text" i18n>Support</span>
71 </div> 71 </div>
72 72
73 <div (click)="showShareModal()" class="action-button action-button-share" role="button"> 73 <div (click)="showShareModal()" class="action-button" role="button">
74 <my-global-icon iconName="share"></my-global-icon> 74 <my-global-icon iconName="share"></my-global-icon>
75 <span class="icon-text" i18n>Share</span> 75 <span class="icon-text" i18n>Share</span>
76 </div> 76 </div>
77 77
78 <div class="action-more" ngbDropdown placement="top" role="button"> 78 <div
79 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
80 *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)"
81 >
82 <div class="action-button action-button-save" ngbDropdownToggle role="button">
83 <my-global-icon iconName="playlist-add"></my-global-icon>
84 <span class="icon-text" i18n>Save</span>
85 </div>
86
87 <div ngbDropdownMenu>
88 <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist>
89 </div>
90 </div>
91
92 <div class="action-dropdown" ngbDropdown placement="top" role="button">
79 <div class="action-button" ngbDropdownToggle role="button"> 93 <div class="action-button" ngbDropdownToggle role="button">
80 <my-global-icon class="more-icon" iconName="more"></my-global-icon> 94 <my-global-icon class="more-icon" iconName="more"></my-global-icon>
81 </div> 95 </div>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index 33d77e62c..ff321fdbc 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -176,7 +176,7 @@ $other-videos-width: 260px;
176 display: flex; 176 display: flex;
177 align-items: center; 177 align-items: center;
178 178
179 .action-button:not(:first-child), .action-more { 179 .action-button:not(:first-child), .action-dropdown {
180 margin-left: 10px; 180 margin-left: 10px;
181 } 181 }
182 182
@@ -212,12 +212,19 @@ $other-videos-width: 260px;
212 } 212 }
213 } 213 }
214 214
215 &.action-button-save {
216 my-global-icon {
217 top: 0 !important;
218 right: -1px;
219 }
220 }
221
215 .icon-text { 222 .icon-text {
216 margin-left: 3px; 223 margin-left: 3px;
217 } 224 }
218 } 225 }
219 226
220 .action-more { 227 .action-dropdown {
221 display: inline-block; 228 display: inline-block;
222 229
223 .dropdown-menu .dropdown-item { 230 .dropdown-menu .dropdown-item {
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index 0f04441ba..359217f3b 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -59,6 +59,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
59 remoteServerDown = false 59 remoteServerDown = false
60 hotkeys: Hotkey[] 60 hotkeys: Hotkey[]
61 61
62 private currentTime: number
62 private paramsSub: Subscription 63 private paramsSub: Subscription
63 64
64 constructor ( 65 constructor (
@@ -114,10 +115,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
114 ) 115 )
115 .subscribe(([ video, captionsResult ]) => { 116 .subscribe(([ video, captionsResult ]) => {
116 const startTime = this.route.snapshot.queryParams.start 117 const startTime = this.route.snapshot.queryParams.start
118 const stopTime = this.route.snapshot.queryParams.stop
117 const subtitle = this.route.snapshot.queryParams.subtitle 119 const subtitle = this.route.snapshot.queryParams.subtitle
118 const playerMode = this.route.snapshot.queryParams.mode 120 const playerMode = this.route.snapshot.queryParams.mode
119 121
120 this.onVideoFetched(video, captionsResult.data, { startTime, subtitle, playerMode }) 122 this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
121 .catch(err => this.handleError(err)) 123 .catch(err => this.handleError(err))
122 }) 124 })
123 }) 125 })
@@ -219,7 +221,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
219 showShareModal () { 221 showShareModal () {
220 const currentTime = this.player ? this.player.currentTime() : undefined 222 const currentTime = this.player ? this.player.currentTime() : undefined
221 223
222 this.videoShareModal.show(currentTime) 224 this.videoShareModal.show(this.currentTime)
223 } 225 }
224 226
225 showDownloadModal (event: Event) { 227 showDownloadModal (event: Event) {
@@ -371,7 +373,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
371 private async onVideoFetched ( 373 private async onVideoFetched (
372 video: VideoDetails, 374 video: VideoDetails,
373 videoCaptions: VideoCaption[], 375 videoCaptions: VideoCaption[],
374 urlOptions: { startTime?: number, subtitle?: string, playerMode?: string } 376 urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string }
375 ) { 377 ) {
376 this.video = video 378 this.video = video
377 379
@@ -379,6 +381,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
379 this.descriptionLoading = false 381 this.descriptionLoading = false
380 this.completeDescriptionShown = false 382 this.completeDescriptionShown = false
381 this.remoteServerDown = false 383 this.remoteServerDown = false
384 this.currentTime = undefined
382 385
383 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) 386 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
384 // If we are at the end of the video, reset the timer 387 // If we are at the end of the video, reset the timer
@@ -420,6 +423,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
420 inactivityTimeout: 2500, 423 inactivityTimeout: 2500,
421 poster: this.video.previewUrl, 424 poster: this.video.previewUrl,
422 startTime, 425 startTime,
426 stopTime: urlOptions.stopTime,
423 427
424 theaterMode: true, 428 theaterMode: true,
425 captions: videoCaptions.length !== 0, 429 captions: videoCaptions.length !== 0,
@@ -466,6 +470,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
466 this.zone.runOutsideAngular(async () => { 470 this.zone.runOutsideAngular(async () => {
467 this.player = await PeertubePlayerManager.initialize(mode, options) 471 this.player = await PeertubePlayerManager.initialize(mode, options)
468 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) 472 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
473
474 this.player.on('timeupdate', () => {
475 this.currentTime = Math.floor(this.player.currentTime())
476 })
469 }) 477 })
470 478
471 this.setVideoDescriptionHTML() 479 this.setVideoDescriptionHTML()
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts
index 58988ffd1..69a9232ce 100644
--- a/client/src/app/videos/videos-routing.module.ts
+++ b/client/src/app/videos/videos-routing.module.ts
@@ -78,11 +78,7 @@ const videosRoutes: Routes = [
78 } 78 }
79 }, 79 },
80 { 80 {
81 path: 'watch/:uuid/comments/:commentId', 81 path: 'watch',
82 redirectTo: 'watch/:uuid'
83 },
84 {
85 path: 'watch/:uuid',
86 loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', 82 loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule',
87 data: { 83 data: {
88 preload: 3000 84 preload: 3000