diff options
55 files changed, 961 insertions, 94 deletions
diff --git a/CREDITS.md b/CREDITS.md index 716f3fca2..1f7aaad7a 100644 --- a/CREDITS.md +++ b/CREDITS.md | |||
@@ -206,6 +206,9 @@ | |||
206 | 206 | ||
207 | # Design | 207 | # Design |
208 | 208 | ||
209 | By [Olivier Massain](https://twitter.com/omassain) | 209 | * [Olivier Massain](https://twitter.com/omassain) |
210 | 210 | ||
211 | Icons from [Robbie Pearce](https://robbiepearce.com/softies/) | 211 | # Icons |
212 | |||
213 | * [Robbie Pearce](https://robbiepearce.com/softies/) | ||
214 | * playlist add by Google | ||
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 { | |||
22 | import { | 22 | import { |
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' |
25 | import { | ||
26 | MyAccountVideoPlaylistElementsComponent | ||
27 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' | ||
25 | 28 | ||
26 | const myAccountRoutes: Routes = [ | 29 | const 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 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { Notifier } from '@app/core' | ||
3 | import { AuthService } from '../../core/auth' | ||
4 | import { ConfirmService } from '../../core/confirm' | ||
5 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
6 | import { Video } from '@app/shared/video/video.model' | ||
7 | import { Subscription } from 'rxjs' | ||
8 | import { ActivatedRoute } from '@angular/router' | ||
9 | import { 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 | }) | ||
16 | export 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' |
34 | import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' | 34 | import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' |
35 | import { | ||
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 @@ | |||
1 | p-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 @@ | |||
1 | import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { 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 | }) | ||
17 | export 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 | ||
31 | export type GlobalIconName = keyof typeof icons | 32 | export 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 | ||
10 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | 10 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' |
11 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 11 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' |
12 | import { KeyFilterModule } from 'primeng/keyfilter' | ||
12 | 13 | ||
13 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 14 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
14 | import { ButtonComponent } from './buttons/button.component' | 15 | import { ButtonComponent } from './buttons/button.component' |
@@ -49,6 +50,7 @@ import { | |||
49 | VideoValidatorsService | 50 | VideoValidatorsService |
50 | } from '@app/shared/forms' | 51 | } from '@app/shared/forms' |
51 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 52 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
53 | import { InputMaskModule } from 'primeng/inputmask' | ||
52 | import { ScreenService } from '@app/shared/misc/screen.service' | 54 | import { ScreenService } from '@app/shared/misc/screen.service' |
53 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' | 55 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' |
54 | import { VideoCaptionService } from '@app/shared/video-caption' | 56 | import { VideoCaptionService } from '@app/shared/video-caption' |
@@ -74,6 +76,8 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist. | |||
74 | import { ImageUploadComponent } from '@app/shared/images/image-upload.component' | 76 | import { ImageUploadComponent } from '@app/shared/images/image-upload.component' |
75 | import { GlobalIconComponent } from '@app/shared/images/global-icon.component' | 77 | import { GlobalIconComponent } from '@app/shared/images/global-icon.component' |
76 | import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' | 78 | import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' |
79 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' | ||
80 | import { 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 | |||
89 | input[type=text] { | ||
90 | @include peertube-input-text(200px); | ||
91 | |||
92 | display: block; | ||
93 | } | ||
94 | |||
95 | input[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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
3 | import { AuthService, Notifier } from '@app/core' | ||
4 | import { forkJoin } from 'rxjs' | ||
5 | import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' | ||
6 | import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { secondsToTime, timeToInt } from '../../../assets/player/utils' | ||
9 | |||
10 | type 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 | }) | ||
24 | export 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 | }) |
9 | export class VideoPlaylistMiniatureComponent { | 9 | export 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 @@ | |||
1 | import { catchError, map, switchMap } from 'rxjs/operators' | 1 | import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators' |
2 | import { Injectable } from '@angular/core' | 2 | import { Injectable } from '@angular/core' |
3 | import { Observable } from 'rxjs' | 3 | import { Observable, ReplaySubject, Subject } from 'rxjs' |
4 | import { RestExtractor } from '../rest/rest-extractor.service' | 4 | import { RestExtractor } from '../rest/rest-extractor.service' |
5 | import { HttpClient } from '@angular/common/http' | 5 | import { HttpClient, HttpParams } from '@angular/common/http' |
6 | import { ResultList } from '../../../../../shared' | 6 | import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared' |
7 | import { environment } from '../../../environments/environment' | 7 | import { environment } from '../../../environments/environment' |
8 | import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' | 8 | import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' |
9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
@@ -15,16 +15,31 @@ import { ServerService } from '@app/core' | |||
15 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | 15 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' |
16 | import { AccountService } from '@app/shared/account/account.service' | 16 | import { AccountService } from '@app/shared/account/account.service' |
17 | import { Account } from '@app/shared/account/account.model' | 17 | import { Account } from '@app/shared/account/account.model' |
18 | import { RestService } from '@app/shared/rest' | ||
19 | import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' | ||
18 | 20 | ||
19 | @Injectable() | 21 | @Injectable() |
20 | export class VideoPlaylistService { | 22 | export 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' | |||
31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' | 31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' |
32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
33 | import { I18n } from '@ngx-translate/i18n-polyfill' | 33 | import { I18n } from '@ngx-translate/i18n-polyfill' |
34 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
35 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
34 | 36 | ||
35 | export interface VideosProvider { | 37 | export 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 | ||
8 | const videoWatchRoutes: Routes = [ | 8 | const 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 |
diff --git a/client/src/assets/images/global/add.html b/client/src/assets/images/global/add.html index bfb0a52bc..34f497056 100644 --- a/client/src/assets/images/global/add.html +++ b/client/src/assets/images/global/add.html | |||
@@ -2,9 +2,9 @@ | |||
2 | <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | 2 | <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> |
3 | <g transform="translate(-92.000000, -115.000000)"> | 3 | <g transform="translate(-92.000000, -115.000000)"> |
4 | <g id="2" transform="translate(92.000000, 115.000000)"> | 4 | <g id="2" transform="translate(92.000000, 115.000000)"> |
5 | <circle id="Oval-1" stroke="#ffffff" stroke-width="2" cx="12" cy="12" r="10"></circle> | 5 | <circle id="Oval-1" stroke="#000000" stroke-width="2" cx="12" cy="12" r="10"></circle> |
6 | <rect id="Rectangle-1" fill="#ffffff" x="11" y="7" width="2" height="10" rx="1"></rect> | 6 | <rect id="Rectangle-1" fill="#000000" x="11" y="7" width="2" height="10" rx="1"></rect> |
7 | <rect id="Rectangle-1" fill="#ffffff" x="7" y="11" width="10" height="2" rx="1"></rect> | 7 | <rect id="Rectangle-1" fill="#000000" x="7" y="11" width="10" height="2" rx="1"></rect> |
8 | </g> | 8 | </g> |
9 | </g> | 9 | </g> |
10 | </g> | 10 | </g> |
diff --git a/client/src/assets/images/video/playlist-add.html b/client/src/assets/images/video/playlist-add.html new file mode 100644 index 000000000..ada845c75 --- /dev/null +++ b/client/src/assets/images/video/playlist-add.html | |||
@@ -0,0 +1,10 @@ | |||
1 | <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" | ||
2 | viewBox="0 0 426.667 426.667" xml:space="preserve"> | ||
3 | <g fill="#000000"> | ||
4 | <rect x="0" y="64" width="256" height="42.667"/> | ||
5 | <rect x="0" y="149.333" width="256" height="42.667"/> | ||
6 | <rect x="0" y="234.667" width="170.667" height="42.667"/> | ||
7 | <polygon points="341.333,234.667 341.333,149.333 298.667,149.333 298.667,234.667 213.333,234.667 213.333,277.333 | ||
8 | 298.667,277.333 298.667,362.667 341.333,362.667 341.333,277.333 426.667,277.333 426.667,234.667 "/> | ||
9 | </g> | ||
10 | </svg> | ||
diff --git a/client/src/assets/images/video/watch-later.html b/client/src/assets/images/video/watch-later.html new file mode 100644 index 000000000..927afebe4 --- /dev/null +++ b/client/src/assets/images/video/watch-later.html | |||
@@ -0,0 +1,11 @@ | |||
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 80 100" | ||
2 | enable-background="new 0 0 80 80" xml:space="preserve"><g><path fill="#000000" d="M33.3,51.5L33.3,51.5c-1.8,0-3.3-1.4-3.3-3.1V37.3c0-1.7,1.5-3.1,3.3-3.1c0.5,0,1,0.1,1.5,0.4l10.7,5.5 c1,0.5,1.6,1.5,1.6,2.7c0,1.2-0.6,2.2-1.7,2.8l-10.6,5.6C34.3,51.3,33.8,51.5,33.3,51.5z M33.3,36.2c-0.6,0-1.3,0.4-1.3,1.1v11.1 c0,0.6,0.7,1.1,1.3,1.1l0,0c0.2,0,0.4,0,0.5-0.1l10.6-5.6c0.4-0.2,0.6-0.6,0.6-1c0-0.2-0.1-0.6-0.5-0.9l-10.7-5.5 C33.6,36.2,33.4,36.2,33.3,36.2z"/></g> | ||
3 | <g><path fill="#000000" d="M62.9,65H12.1C10.4,65,9,63.6,9,61.9V22.1c0-1.7,1.4-3.1,3.1-3.1h50.8c1.7,0,3.1,1.4,3.1,3.1v39.8 C66,63.6,64.6,65,62.9,65z M12.1,21c-0.6,0-1.1,0.5-1.1,1.1v39.8c0,0.6,0.5,1.1,1.1,1.1h50.8c0.6,0,1.1-0.5,1.1-1.1V22.1 c0-0.6-0.5-1.1-1.1-1.1H12.1z"/></g> | ||
4 | <g><path fill="#000000" d="M63,16h-2c0-1-0.4-1-0.9-1H14.9c-0.5,0-0.9,0-0.9,1h-2c0-2,1.3-3,2.9-3h45.3C61.7,13,63,14,63,16z"/></g> | ||
5 | <g><path fill="#000000" d="M58,11h-2c0-1-0.4-1-0.5-1H19.5c-0.1,0-0.5,0-0.5,1h-2c0-2,1.1-3,2.5-3h36.1C56.9,8,58,9,58,11z"/></g> | ||
6 | <g><path fill="#000000" d="M68,29v-2c4,0,6.5-2.9,6.5-6.5S72,14,68,14v-2c5,0,8.5,3.8,8.5,8.5S73,29,68,29z"/></g> | ||
7 | <g><polygon fill="#000000" points="71.3,18.7 65.6,13 71.3,7.3 72.7,8.7 68.4,13 72.7,17.3 "/></g> | ||
8 | <text x="0" y="95" fill="#000000" font-size="5px" font-weight="bold" | ||
9 | font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Yaroslav Samoylov</text> | ||
10 | <text x="0" y="100" fill="#000000" font-size="5px" font-weight="bold" | ||
11 | font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg> | ||
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 7631d095f..6cdd54372 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts | |||
@@ -49,6 +49,7 @@ export type CommonOptions = { | |||
49 | inactivityTimeout: number | 49 | inactivityTimeout: number |
50 | poster: string | 50 | poster: string |
51 | startTime: number | string | 51 | startTime: number | string |
52 | stopTime: number | string | ||
52 | 53 | ||
53 | theaterMode: boolean | 54 | theaterMode: boolean |
54 | captions: boolean | 55 | captions: boolean |
@@ -199,10 +200,10 @@ export class PeertubePlayerManager { | |||
199 | autoplay, // Use peertube plugin autoplay because we get the file by webtorrent | 200 | autoplay, // Use peertube plugin autoplay because we get the file by webtorrent |
200 | videoViewUrl: commonOptions.videoViewUrl, | 201 | videoViewUrl: commonOptions.videoViewUrl, |
201 | videoDuration: commonOptions.videoDuration, | 202 | videoDuration: commonOptions.videoDuration, |
202 | startTime: commonOptions.startTime, | ||
203 | userWatching: commonOptions.userWatching, | 203 | userWatching: commonOptions.userWatching, |
204 | subtitle: commonOptions.subtitle, | 204 | subtitle: commonOptions.subtitle, |
205 | videoCaptions: commonOptions.videoCaptions | 205 | videoCaptions: commonOptions.videoCaptions, |
206 | stopTime: commonOptions.stopTime | ||
206 | } | 207 | } |
207 | } | 208 | } |
208 | 209 | ||
@@ -210,6 +211,7 @@ export class PeertubePlayerManager { | |||
210 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | 211 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { |
211 | redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, | 212 | redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, |
212 | type: 'application/x-mpegURL', | 213 | type: 'application/x-mpegURL', |
214 | startTime: commonOptions.startTime, | ||
213 | src: p2pMediaLoaderOptions.playlistUrl | 215 | src: p2pMediaLoaderOptions.playlistUrl |
214 | } | 216 | } |
215 | 217 | ||
@@ -254,7 +256,8 @@ export class PeertubePlayerManager { | |||
254 | autoplay, | 256 | autoplay, |
255 | videoDuration: commonOptions.videoDuration, | 257 | videoDuration: commonOptions.videoDuration, |
256 | playerElement: commonOptions.playerElement, | 258 | playerElement: commonOptions.playerElement, |
257 | videoFiles: webtorrentOptions.videoFiles | 259 | videoFiles: webtorrentOptions.videoFiles, |
260 | startTime: commonOptions.startTime | ||
258 | } | 261 | } |
259 | Object.assign(plugins, { webtorrent }) | 262 | Object.assign(plugins, { webtorrent }) |
260 | 263 | ||
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index 92ac57cf5..3991e4627 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts | |||
@@ -22,7 +22,6 @@ import { | |||
22 | 22 | ||
23 | const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') | 23 | const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') |
24 | class PeerTubePlugin extends Plugin { | 24 | class PeerTubePlugin extends Plugin { |
25 | private readonly startTime: number = 0 | ||
26 | private readonly videoViewUrl: string | 25 | private readonly videoViewUrl: string |
27 | private readonly videoDuration: number | 26 | private readonly videoDuration: number |
28 | private readonly CONSTANTS = { | 27 | private readonly CONSTANTS = { |
@@ -35,13 +34,11 @@ class PeerTubePlugin extends Plugin { | |||
35 | 34 | ||
36 | private videoViewInterval: any | 35 | private videoViewInterval: any |
37 | private userWatchingVideoInterval: any | 36 | private userWatchingVideoInterval: any |
38 | private qualityObservationTimer: any | ||
39 | private lastResolutionChange: ResolutionUpdateData | 37 | private lastResolutionChange: ResolutionUpdateData |
40 | 38 | ||
41 | constructor (player: videojs.Player, options: PeerTubePluginOptions) { | 39 | constructor (player: videojs.Player, options: PeerTubePluginOptions) { |
42 | super(player, options) | 40 | super(player, options) |
43 | 41 | ||
44 | this.startTime = timeToInt(options.startTime) | ||
45 | this.videoViewUrl = options.videoViewUrl | 42 | this.videoViewUrl = options.videoViewUrl |
46 | this.videoDuration = options.videoDuration | 43 | this.videoDuration = options.videoDuration |
47 | this.videoCaptions = options.videoCaptions | 44 | this.videoCaptions = options.videoCaptions |
@@ -84,6 +81,14 @@ class PeerTubePlugin extends Plugin { | |||
84 | saveMuteInStore(this.player.muted()) | 81 | saveMuteInStore(this.player.muted()) |
85 | }) | 82 | }) |
86 | 83 | ||
84 | if (options.stopTime) { | ||
85 | const stopTime = timeToInt(options.stopTime) | ||
86 | |||
87 | this.player.on('timeupdate', () => { | ||
88 | if (this.player.currentTime() > stopTime) this.player.pause() | ||
89 | }) | ||
90 | } | ||
91 | |||
87 | this.player.textTracks().on('change', () => { | 92 | this.player.textTracks().on('change', () => { |
88 | const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { | 93 | const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { |
89 | return t.kind === 'captions' && t.mode === 'showing' | 94 | return t.kind === 'captions' && t.mode === 'showing' |
@@ -109,10 +114,7 @@ class PeerTubePlugin extends Plugin { | |||
109 | } | 114 | } |
110 | 115 | ||
111 | dispose () { | 116 | dispose () { |
112 | clearTimeout(this.qualityObservationTimer) | 117 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) |
113 | |||
114 | clearInterval(this.videoViewInterval) | ||
115 | |||
116 | if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) | 118 | if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) |
117 | } | 119 | } |
118 | 120 | ||
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 79a5a6c4d..a96b0bc8c 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts | |||
@@ -41,12 +41,13 @@ type PeerTubePluginOptions = { | |||
41 | autoplay: boolean | 41 | autoplay: boolean |
42 | videoViewUrl: string | 42 | videoViewUrl: string |
43 | videoDuration: number | 43 | videoDuration: number |
44 | startTime: number | string | ||
45 | 44 | ||
46 | userWatching?: UserWatching | 45 | userWatching?: UserWatching |
47 | subtitle?: string | 46 | subtitle?: string |
48 | 47 | ||
49 | videoCaptions: VideoJSCaption[] | 48 | videoCaptions: VideoJSCaption[] |
49 | |||
50 | stopTime: number | string | ||
50 | } | 51 | } |
51 | 52 | ||
52 | type WebtorrentPluginOptions = { | 53 | type WebtorrentPluginOptions = { |
@@ -56,12 +57,16 @@ type WebtorrentPluginOptions = { | |||
56 | videoDuration: number | 57 | videoDuration: number |
57 | 58 | ||
58 | videoFiles: VideoFile[] | 59 | videoFiles: VideoFile[] |
60 | |||
61 | startTime: number | string | ||
59 | } | 62 | } |
60 | 63 | ||
61 | type P2PMediaLoaderPluginOptions = { | 64 | type P2PMediaLoaderPluginOptions = { |
62 | redundancyBaseUrls: string[] | 65 | redundancyBaseUrls: string[] |
63 | type: string | 66 | type: string |
64 | src: string | 67 | src: string |
68 | |||
69 | startTime: number | string | ||
65 | } | 70 | } |
66 | 71 | ||
67 | type VideoJSPluginOptions = { | 72 | type VideoJSPluginOptions = { |
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index 8d87567c2..54f131310 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts | |||
@@ -42,7 +42,7 @@ function timeToInt (time: number | string) { | |||
42 | if (!time) return 0 | 42 | if (!time) return 0 |
43 | if (typeof time === 'number') return time | 43 | if (typeof time === 'number') return time |
44 | 44 | ||
45 | const reg = /^((\d+)h)?((\d+)m)?((\d+)s?)?$/ | 45 | const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/ |
46 | const matches = time.match(reg) | 46 | const matches = time.match(reg) |
47 | 47 | ||
48 | if (!matches) return 0 | 48 | if (!matches) return 0 |
@@ -54,18 +54,27 @@ function timeToInt (time: number | string) { | |||
54 | return hours * 3600 + minutes * 60 + seconds | 54 | return hours * 3600 + minutes * 60 + seconds |
55 | } | 55 | } |
56 | 56 | ||
57 | function secondsToTime (seconds: number) { | 57 | function secondsToTime (seconds: number, full = false, symbol?: string) { |
58 | let time = '' | 58 | let time = '' |
59 | 59 | ||
60 | const hourSymbol = (symbol || 'h') | ||
61 | const minuteSymbol = (symbol || 'm') | ||
62 | const secondsSymbol = full ? '' : 's' | ||
63 | |||
60 | let hours = Math.floor(seconds / 3600) | 64 | let hours = Math.floor(seconds / 3600) |
61 | if (hours >= 1) time = hours + 'h' | 65 | if (hours >= 1) time = hours + hourSymbol |
66 | else if (full) time = '0' + hourSymbol | ||
62 | 67 | ||
63 | seconds %= 3600 | 68 | seconds %= 3600 |
64 | let minutes = Math.floor(seconds / 60) | 69 | let minutes = Math.floor(seconds / 60) |
65 | if (minutes >= 1) time += minutes + 'm' | 70 | if (minutes >= 1 && minutes < 10 && full) time += '0' + minutes + minuteSymbol |
71 | else if (minutes >= 1) time += minutes + minuteSymbol | ||
72 | else if (full) time += '00' + minuteSymbol | ||
66 | 73 | ||
67 | seconds %= 60 | 74 | seconds %= 60 |
68 | if (seconds >= 1) time += seconds + 's' | 75 | if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol |
76 | else if (seconds >= 1) time += seconds + secondsSymbol | ||
77 | else if (full) time += '00' | ||
69 | 78 | ||
70 | return time | 79 | return time |
71 | } | 80 | } |
@@ -131,6 +140,7 @@ export { | |||
131 | getRtcConfig, | 140 | getRtcConfig, |
132 | toTitleCase, | 141 | toTitleCase, |
133 | timeToInt, | 142 | timeToInt, |
143 | secondsToTime, | ||
134 | buildVideoLink, | 144 | buildVideoLink, |
135 | buildVideoEmbed, | 145 | buildVideoEmbed, |
136 | videoFileMaxByResolution, | 146 | videoFileMaxByResolution, |
diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts index c69bf31fa..c7182acc9 100644 --- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts | |||
@@ -6,7 +6,7 @@ import * as WebTorrent from 'webtorrent' | |||
6 | import { VideoFile } from '../../../../../shared/models/videos/video.model' | 6 | import { VideoFile } from '../../../../../shared/models/videos/video.model' |
7 | import { renderVideo } from './video-renderer' | 7 | import { renderVideo } from './video-renderer' |
8 | import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' | 8 | import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' |
9 | import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' | 9 | import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' |
10 | import { PeertubeChunkStore } from './peertube-chunk-store' | 10 | import { PeertubeChunkStore } from './peertube-chunk-store' |
11 | import { | 11 | import { |
12 | getAverageBandwidthInStore, | 12 | getAverageBandwidthInStore, |
@@ -73,6 +73,8 @@ class WebTorrentPlugin extends Plugin { | |||
73 | constructor (player: videojs.Player, options: WebtorrentPluginOptions) { | 73 | constructor (player: videojs.Player, options: WebtorrentPluginOptions) { |
74 | super(player, options) | 74 | super(player, options) |
75 | 75 | ||
76 | this.startTime = timeToInt(options.startTime) | ||
77 | |||
76 | // Disable auto play on iOS | 78 | // Disable auto play on iOS |
77 | this.autoplay = options.autoplay && this.isIOS() === false | 79 | this.autoplay = options.autoplay && this.isIOS() === false |
78 | this.playerRefusedP2P = !getStoredWebTorrentEnabled() | 80 | this.playerRefusedP2P = !getStoredWebTorrentEnabled() |
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 59b2f42a5..3eefdb6fb 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss | |||
@@ -515,4 +515,3 @@ | |||
515 | align-items: center; | 515 | align-items: center; |
516 | } | 516 | } |
517 | } | 517 | } |
518 | |||
diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss index 56ca4c2d3..deabbf6d4 100644 --- a/client/src/sass/include/_variables.scss +++ b/client/src/sass/include/_variables.scss | |||
@@ -44,6 +44,8 @@ $footer-margin: 30px; | |||
44 | 44 | ||
45 | $footer-border-color: $header-border-color; | 45 | $footer-border-color: $header-border-color; |
46 | 46 | ||
47 | $separator-border-color: rgba(0, 0, 0, 0.10); | ||
48 | |||
47 | $video-thumbnail-height: 122px; | 49 | $video-thumbnail-height: 122px; |
48 | $video-thumbnail-width: 223px; | 50 | $video-thumbnail-width: 223px; |
49 | 51 | ||
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 32bf42e12..28c10c75c 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts | |||
@@ -168,6 +168,7 @@ class PeerTubeEmbed { | |||
168 | subtitle: string | 168 | subtitle: string |
169 | enableApi = false | 169 | enableApi = false |
170 | startTime: number | string = 0 | 170 | startTime: number | string = 0 |
171 | stopTime: number | string | ||
171 | mode: PlayerMode | 172 | mode: PlayerMode |
172 | scope = 'peertube' | 173 | scope = 'peertube' |
173 | 174 | ||
@@ -262,6 +263,7 @@ class PeerTubeEmbed { | |||
262 | this.scope = this.getParamString(params, 'scope', this.scope) | 263 | this.scope = this.getParamString(params, 'scope', this.scope) |
263 | this.subtitle = this.getParamString(params, 'subtitle') | 264 | this.subtitle = this.getParamString(params, 'subtitle') |
264 | this.startTime = this.getParamString(params, 'start') | 265 | this.startTime = this.getParamString(params, 'start') |
266 | this.stopTime = this.getParamString(params, 'stop') | ||
265 | 267 | ||
266 | this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent' | 268 | this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent' |
267 | } catch (err) { | 269 | } catch (err) { |
@@ -306,6 +308,7 @@ class PeerTubeEmbed { | |||
306 | loop: this.loop, | 308 | loop: this.loop, |
307 | captions: videoCaptions.length !== 0, | 309 | captions: videoCaptions.length !== 0, |
308 | startTime: this.startTime, | 310 | startTime: this.startTime, |
311 | stopTime: this.stopTime, | ||
309 | subtitle: this.subtitle, | 312 | subtitle: this.subtitle, |
310 | 313 | ||
311 | videoCaptions, | 314 | videoCaptions, |
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 5758c8227..f7edbddf3 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts | |||
@@ -38,6 +38,7 @@ import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../h | |||
38 | import { meRouter } from './me' | 38 | import { meRouter } from './me' |
39 | import { deleteUserToken } from '../../../lib/oauth-model' | 39 | import { deleteUserToken } from '../../../lib/oauth-model' |
40 | import { myBlocklistRouter } from './my-blocklist' | 40 | import { myBlocklistRouter } from './my-blocklist' |
41 | import { myVideoPlaylistsRouter } from './my-video-playlists' | ||
41 | import { myVideosHistoryRouter } from './my-history' | 42 | import { myVideosHistoryRouter } from './my-history' |
42 | import { myNotificationsRouter } from './my-notifications' | 43 | import { myNotificationsRouter } from './my-notifications' |
43 | import { Notifier } from '../../../lib/notifier' | 44 | import { Notifier } from '../../../lib/notifier' |
@@ -60,6 +61,7 @@ usersRouter.use('/', myNotificationsRouter) | |||
60 | usersRouter.use('/', mySubscriptionsRouter) | 61 | usersRouter.use('/', mySubscriptionsRouter) |
61 | usersRouter.use('/', myBlocklistRouter) | 62 | usersRouter.use('/', myBlocklistRouter) |
62 | usersRouter.use('/', myVideosHistoryRouter) | 63 | usersRouter.use('/', myVideosHistoryRouter) |
64 | usersRouter.use('/', myVideoPlaylistsRouter) | ||
63 | usersRouter.use('/', meRouter) | 65 | usersRouter.use('/', meRouter) |
64 | 66 | ||
65 | usersRouter.get('/autocomplete', | 67 | usersRouter.get('/autocomplete', |
diff --git a/server/controllers/api/users/my-video-playlists.ts b/server/controllers/api/users/my-video-playlists.ts new file mode 100644 index 000000000..1ec175f64 --- /dev/null +++ b/server/controllers/api/users/my-video-playlists.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | import * as express from 'express' | ||
2 | import { asyncMiddleware, authenticate } from '../../../middlewares' | ||
3 | import { UserModel } from '../../../models/account/user' | ||
4 | import { doVideosInPlaylistExistValidator } from '../../../middlewares/validators/videos/video-playlists' | ||
5 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
6 | import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model' | ||
7 | |||
8 | const myVideoPlaylistsRouter = express.Router() | ||
9 | |||
10 | myVideoPlaylistsRouter.get('/me/video-playlists/videos-exist', | ||
11 | authenticate, | ||
12 | doVideosInPlaylistExistValidator, | ||
13 | asyncMiddleware(doVideosInPlaylistExist) | ||
14 | ) | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
19 | myVideoPlaylistsRouter | ||
20 | } | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | async function doVideosInPlaylistExist (req: express.Request, res: express.Response) { | ||
25 | const videoIds = req.query.videoIds as number[] | ||
26 | const user = res.locals.oauth.token.User as UserModel | ||
27 | |||
28 | const results = await VideoPlaylistModel.listPlaylistIdsOf(user.Account.id, videoIds) | ||
29 | |||
30 | const existObject: VideoExistInPlaylist = {} | ||
31 | |||
32 | for (const videoId of videoIds) { | ||
33 | existObject[videoId] = [] | ||
34 | } | ||
35 | |||
36 | for (const result of results) { | ||
37 | for (const element of result.VideoPlaylistElements) { | ||
38 | existObject[element.videoId].push({ | ||
39 | playlistId: result.id, | ||
40 | startTimestamp: element.startTimestamp, | ||
41 | stopTimestamp: element.stopTimestamp | ||
42 | }) | ||
43 | } | ||
44 | } | ||
45 | |||
46 | return res.json(existObject) | ||
47 | } | ||
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts index 145764d35..49432d3aa 100644 --- a/server/controllers/api/video-playlist.ts +++ b/server/controllers/api/video-playlist.ts | |||
@@ -291,23 +291,26 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response) | |||
291 | videoId: video.id | 291 | videoId: video.id |
292 | }, { transaction: t }) | 292 | }, { transaction: t }) |
293 | 293 | ||
294 | // If the user did not set a thumbnail, automatically take the video thumbnail | 294 | videoPlaylist.updatedAt = new Date() |
295 | if (playlistElement.position === 1) { | 295 | await videoPlaylist.save({ transaction: t }) |
296 | const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) | ||
297 | |||
298 | if (await pathExists(playlistThumbnailPath) === false) { | ||
299 | logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) | ||
300 | |||
301 | const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) | ||
302 | await copy(videoThumbnailPath, playlistThumbnailPath) | ||
303 | } | ||
304 | } | ||
305 | 296 | ||
306 | await sendUpdateVideoPlaylist(videoPlaylist, t) | 297 | await sendUpdateVideoPlaylist(videoPlaylist, t) |
307 | 298 | ||
308 | return playlistElement | 299 | return playlistElement |
309 | }) | 300 | }) |
310 | 301 | ||
302 | // If the user did not set a thumbnail, automatically take the video thumbnail | ||
303 | if (playlistElement.position === 1) { | ||
304 | const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) | ||
305 | |||
306 | if (await pathExists(playlistThumbnailPath) === false) { | ||
307 | logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) | ||
308 | |||
309 | const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) | ||
310 | await copy(videoThumbnailPath, playlistThumbnailPath) | ||
311 | } | ||
312 | } | ||
313 | |||
311 | logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) | 314 | logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) |
312 | 315 | ||
313 | return res.json({ | 316 | return res.json({ |
@@ -328,6 +331,9 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re | |||
328 | 331 | ||
329 | const element = await videoPlaylistElement.save({ transaction: t }) | 332 | const element = await videoPlaylistElement.save({ transaction: t }) |
330 | 333 | ||
334 | videoPlaylist.updatedAt = new Date() | ||
335 | await videoPlaylist.save({ transaction: t }) | ||
336 | |||
331 | await sendUpdateVideoPlaylist(videoPlaylist, t) | 337 | await sendUpdateVideoPlaylist(videoPlaylist, t) |
332 | 338 | ||
333 | return element | 339 | return element |
@@ -349,6 +355,9 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo | |||
349 | // Decrease position of the next elements | 355 | // Decrease position of the next elements |
350 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) | 356 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) |
351 | 357 | ||
358 | videoPlaylist.updatedAt = new Date() | ||
359 | await videoPlaylist.save({ transaction: t }) | ||
360 | |||
352 | await sendUpdateVideoPlaylist(videoPlaylist, t) | 361 | await sendUpdateVideoPlaylist(videoPlaylist, t) |
353 | 362 | ||
354 | logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) | 363 | logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) |
@@ -390,6 +399,9 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons | |||
390 | // Decrease positions of elements after the old position of our ordered elements (decrease) | 399 | // Decrease positions of elements after the old position of our ordered elements (decrease) |
391 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) | 400 | await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) |
392 | 401 | ||
402 | videoPlaylist.updatedAt = new Date() | ||
403 | await videoPlaylist.save({ transaction: t }) | ||
404 | |||
393 | await sendUpdateVideoPlaylist(videoPlaylist, t) | 405 | await sendUpdateVideoPlaylist(videoPlaylist, t) |
394 | }) | 406 | }) |
395 | 407 | ||
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index 76647fea2..3a3deab0c 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts | |||
@@ -49,12 +49,19 @@ function toValueOrNull (value: string) { | |||
49 | return value | 49 | return value |
50 | } | 50 | } |
51 | 51 | ||
52 | function toArray (value: string) { | 52 | function toArray (value: any) { |
53 | if (value && isArray(value) === false) return [ value ] | 53 | if (value && isArray(value) === false) return [ value ] |
54 | 54 | ||
55 | return value | 55 | return value |
56 | } | 56 | } |
57 | 57 | ||
58 | function toIntArray (value: any) { | ||
59 | if (!value) return [] | ||
60 | if (isArray(value) === false) return [ validator.toInt(value) ] | ||
61 | |||
62 | return value.map(v => validator.toInt(v)) | ||
63 | } | ||
64 | |||
58 | function isFileValid ( | 65 | function isFileValid ( |
59 | files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], | 66 | files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], |
60 | mimeTypeRegex: string, | 67 | mimeTypeRegex: string, |
@@ -97,5 +104,6 @@ export { | |||
97 | isBooleanValid, | 104 | isBooleanValid, |
98 | toIntOrNull, | 105 | toIntOrNull, |
99 | toArray, | 106 | toArray, |
107 | toIntArray, | ||
100 | isFileValid | 108 | isFileValid |
101 | } | 109 | } |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 54c390540..169a98ceb 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -56,7 +56,7 @@ const SORTABLE_COLUMNS = { | |||
56 | 56 | ||
57 | USER_NOTIFICATIONS: [ 'createdAt' ], | 57 | USER_NOTIFICATIONS: [ 'createdAt' ], |
58 | 58 | ||
59 | VIDEO_PLAYLISTS: [ 'createdAt' ] | 59 | VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ] |
60 | } | 60 | } |
61 | 61 | ||
62 | const OAUTH_LIFETIME = { | 62 | const OAUTH_LIFETIME = { |
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index 22b8b8ff1..87d2c7b51 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts | |||
@@ -4,9 +4,9 @@ import { UserRight } from '../../../../shared' | |||
4 | import { logger } from '../../../helpers/logger' | 4 | import { logger } from '../../../helpers/logger' |
5 | import { UserModel } from '../../../models/account/user' | 5 | import { UserModel } from '../../../models/account/user' |
6 | import { areValidationErrors } from '../utils' | 6 | import { areValidationErrors } from '../utils' |
7 | import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos' | 7 | import { isVideoExist, isVideoFileInfoHashValid, isVideoImage } from '../../../helpers/custom-validators/videos' |
8 | import { CONSTRAINTS_FIELDS } from '../../../initializers' | 8 | import { CONSTRAINTS_FIELDS } from '../../../initializers' |
9 | import { isIdOrUUIDValid, isUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc' | 9 | import { isArrayOf, isIdOrUUIDValid, isIdValid, isUUIDValid, toArray, toValueOrNull, toIntArray } from '../../../helpers/custom-validators/misc' |
10 | import { | 10 | import { |
11 | isVideoPlaylistDescriptionValid, | 11 | isVideoPlaylistDescriptionValid, |
12 | isVideoPlaylistExist, | 12 | isVideoPlaylistExist, |
@@ -23,6 +23,7 @@ import { VideoModel } from '../../../models/video/video' | |||
23 | import { authenticatePromiseIfNeeded } from '../../oauth' | 23 | import { authenticatePromiseIfNeeded } from '../../oauth' |
24 | import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' | 24 | import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' |
25 | import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' | 25 | import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' |
26 | import { areValidActorHandles } from '../../../helpers/custom-validators/activitypub/actor' | ||
26 | 27 | ||
27 | const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ | 28 | const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ |
28 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 29 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
@@ -305,6 +306,20 @@ const commonVideoPlaylistFiltersValidator = [ | |||
305 | } | 306 | } |
306 | ] | 307 | ] |
307 | 308 | ||
309 | const doVideosInPlaylistExistValidator = [ | ||
310 | query('videoIds') | ||
311 | .customSanitizer(toIntArray) | ||
312 | .custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'), | ||
313 | |||
314 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
315 | logger.debug('Checking areVideosInPlaylistExistValidator parameters', { parameters: req.query }) | ||
316 | |||
317 | if (areValidationErrors(req, res)) return | ||
318 | |||
319 | return next() | ||
320 | } | ||
321 | ] | ||
322 | |||
308 | // --------------------------------------------------------------------------- | 323 | // --------------------------------------------------------------------------- |
309 | 324 | ||
310 | export { | 325 | export { |
@@ -319,7 +334,9 @@ export { | |||
319 | 334 | ||
320 | videoPlaylistElementAPGetValidator, | 335 | videoPlaylistElementAPGetValidator, |
321 | 336 | ||
322 | commonVideoPlaylistFiltersValidator | 337 | commonVideoPlaylistFiltersValidator, |
338 | |||
339 | doVideosInPlaylistExistValidator | ||
323 | } | 340 | } |
324 | 341 | ||
325 | // --------------------------------------------------------------------------- | 342 | // --------------------------------------------------------------------------- |
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 4d2ea0a66..aa42687cd 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts | |||
@@ -317,6 +317,29 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { | |||
317 | }) | 317 | }) |
318 | } | 318 | } |
319 | 319 | ||
320 | static listPlaylistIdsOf (accountId: number, videoIds: number[]) { | ||
321 | const query = { | ||
322 | attributes: [ 'id' ], | ||
323 | where: { | ||
324 | ownerAccountId: accountId | ||
325 | }, | ||
326 | include: [ | ||
327 | { | ||
328 | attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ], | ||
329 | model: VideoPlaylistElementModel.unscoped(), | ||
330 | where: { | ||
331 | videoId: { | ||
332 | [Sequelize.Op.any]: videoIds | ||
333 | } | ||
334 | }, | ||
335 | required: true | ||
336 | } | ||
337 | ] | ||
338 | } | ||
339 | |||
340 | return VideoPlaylistModel.findAll(query) | ||
341 | } | ||
342 | |||
320 | static doesPlaylistExist (url: string) { | 343 | static doesPlaylistExist (url: string) { |
321 | const query = { | 344 | const query = { |
322 | attributes: [], | 345 | attributes: [], |
diff --git a/shared/models/videos/playlist/video-exist-in-playlist.model.ts b/shared/models/videos/playlist/video-exist-in-playlist.model.ts new file mode 100644 index 000000000..71240f51d --- /dev/null +++ b/shared/models/videos/playlist/video-exist-in-playlist.model.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | export type VideoExistInPlaylist = { | ||
2 | [videoId: number ]: { | ||
3 | playlistId: number | ||
4 | startTimestamp?: number | ||
5 | stopTimestamp?: number | ||
6 | }[] | ||
7 | } | ||