aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-03-13 14:18:58 +0100
committerChocobozzz <chocobozzz@cpy.re>2019-03-18 11:17:59 +0100
commite2f01c47e08d26a30ad47068d195b3d21d0df8a1 (patch)
tree21f18ed462d313bfb4ba7a1b5221fdb6b2c35bc1 /client/src
parent15e9d5ca39e0b792f61453fbf3885a0fc446afa7 (diff)
downloadPeerTube-e2f01c47e08d26a30ad47068d195b3d21d0df8a1.tar.gz
PeerTube-e2f01c47e08d26a30ad47068d195b3d21d0df8a1.tar.zst
PeerTube-e2f01c47e08d26a30ad47068d195b3d21d0df8a1.zip
Playlist support in watch page
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html57
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss94
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts102
-rw-r--r--client/src/app/shared/images/global-icon.component.ts3
-rw-r--r--client/src/app/shared/shared.module.ts3
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html73
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss124
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts149
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts3
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html2
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts11
-rw-r--r--client/src/app/videos/+video-watch/video-watch-routing.module.ts8
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html34
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss54
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts197
-rw-r--r--client/src/assets/images/global/play.html9
-rw-r--r--client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts12
-rw-r--r--client/src/assets/player/peertube-plugin.ts10
-rw-r--r--client/src/sass/include/_miniature.scss13
-rw-r--r--client/src/sass/include/_mixins.scss4
20 files changed, 668 insertions, 294 deletions
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
index 67a8b1a91..bc26e198e 100644
--- 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
@@ -5,60 +5,7 @@
5 cdkDropList (cdkDropListDropped)="drop($event)" 5 cdkDropList (cdkDropListDropped)="drop($event)"
6> 6>
7 <div class="video" *ngFor="let video of videos" cdkDrag (cdkDragMoved)="onDragMove($event)"> 7 <div class="video" *ngFor="let video of videos" cdkDrag (cdkDragMoved)="onDragMove($event)">
8 <div class="position">{{ video.playlistElement.position }}</div> 8 <my-video-playlist-element-miniature [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)">
9 9 </my-video-playlist-element-miniature>
10 <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur(video)"></my-video-thumbnail>
11
12 <div class="video-info">
13 <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
14 <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
15 <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video)}}</span>
16 </div>
17
18 <div class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()" autoClose="outside">
19 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more"></my-global-icon>
20
21 <div ngbDropdownMenu>
22 <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)">
23 <my-global-icon iconName="edit"></my-global-icon> <ng-container i18n>Edit starts/stops at</ng-container>
24 </div>
25
26 <div class="timestamp-options" *ngIf="displayTimestampOptions">
27 <div>
28 <my-peertube-checkbox
29 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
30 i18n-labelText labelText="Start at"
31 ></my-peertube-checkbox>
32
33 <my-timestamp-input
34 [timestamp]="timestampOptions.startTimestamp"
35 [maxTimestamp]="video.duration"
36 [disabled]="!timestampOptions.startTimestampEnabled"
37 [(ngModel)]="timestampOptions.startTimestamp"
38 ></my-timestamp-input>
39 </div>
40
41 <div>
42 <my-peertube-checkbox
43 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
44 i18n-labelText labelText="Stop at"
45 ></my-peertube-checkbox>
46
47 <my-timestamp-input
48 [timestamp]="timestampOptions.stopTimestamp"
49 [maxTimestamp]="video.duration"
50 [disabled]="!timestampOptions.stopTimestampEnabled"
51 [(ngModel)]="timestampOptions.stopTimestamp"
52 ></my-timestamp-input>
53 </div>
54
55 <input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)">
56 </div>
57
58 <span class="dropdown-item" (click)="removeFromPlaylist(video)">
59 <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{playlist?.displayName}}</ng-container>
60 </span>
61 </div>
62 </div>
63 </div> 10 </div>
64</div> 11</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
index 4ac89d08f..b05af0490 100644
--- 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
@@ -2,100 +2,6 @@
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature'; 3@import '_miniature';
4 4
5.video, .cdk-drag-preview {
6 display: flex;
7 align-items: center;
8 background-color: var(--mainBackgroundColor);
9 cursor: pointer;
10 padding: 10px;
11 border-bottom: 1px solid $separator-border-color;
12
13 &:hover {
14 background-color: rgba(0, 0, 0, 0.05);
15
16 .more {
17 display: block;
18 }
19 }
20
21 .position {
22 font-weight: $font-semibold;
23 margin-right: 10px;
24 color: $grey-foreground-color;
25 min-width: 20px;
26 }
27
28 my-video-thumbnail {
29 display: flex; // Avoids an issue with line-height that adds space below the element
30 margin-right: 10px;
31
32 /deep/ .video-thumbnail {
33 @include miniature-thumbnail(130px, 72px);
34 }
35 }
36
37 .video-info {
38 display: flex;
39 flex-direction: column;
40
41 a {
42 @include disable-default-a-behaviour;
43
44 color: var(--mainForegroundColor);
45 }
46
47 .video-info-name {
48 font-size: 18px;
49 font-weight: $font-semibold;
50 }
51
52 .video-info-account, .video-info-timestamp {
53 color: $grey-foreground-color;
54 }
55 }
56
57 .more {
58 justify-self: flex-end;
59 margin-left: auto;
60 cursor: pointer;
61 display: none;
62
63 &.show {
64 display: block;
65 }
66
67 .icon-more {
68 @include apply-svg-color($grey-foreground-color);
69
70 &::after {
71 border: none;
72 }
73 }
74
75 .dropdown-item {
76 @include dropdown-with-icon-item;
77 }
78
79 .timestamp-options {
80 padding-top: 0;
81 padding-left: 35px;
82 margin-bottom: 15px;
83
84 > div {
85 display: flex;
86 align-items: center;
87 }
88
89 input {
90 @include peertube-button;
91 @include orange-button;
92
93 margin-top: 10px;
94 }
95 }
96 }
97}
98
99// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples 5// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples
100.cdk-drag-preview { 6.cdk-drag-preview {
101 box-sizing: border-box; 7 box-sizing: border-box;
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
index 4076a3721..dcf470be3 100644
--- 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
@@ -1,4 +1,4 @@
1import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' 1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { Notifier, ServerService } from '@app/core' 2import { Notifier, ServerService } from '@app/core'
3import { AuthService } from '../../core/auth' 3import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm' 4import { ConfirmService } from '../../core/confirm'
@@ -10,9 +10,6 @@ import { VideoService } from '@app/shared/video/video.service'
10import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 10import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
11import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 11import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
12import { I18n } from '@ngx-translate/i18n-polyfill' 12import { I18n } from '@ngx-translate/i18n-polyfill'
13import { secondsToTime } from '../../../assets/player/utils'
14import { VideoPlaylistElementUpdate } from '@shared/models'
15import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
16import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' 13import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop'
17import { throttleTime } from 'rxjs/operators' 14import { throttleTime } from 'rxjs/operators'
18 15
@@ -22,8 +19,6 @@ import { throttleTime } from 'rxjs/operators'
22 styleUrls: [ './my-account-video-playlist-elements.component.scss' ] 19 styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
23}) 20})
24export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy { 21export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
25 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
26
27 videos: Video[] = [] 22 videos: Video[] = []
28 playlist: VideoPlaylist 23 playlist: VideoPlaylist
29 24
@@ -33,15 +28,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
33 totalItems: null 28 totalItems: null
34 } 29 }
35 30
36 displayTimestampOptions = false
37
38 timestampOptions: {
39 startTimestampEnabled: boolean
40 startTimestamp: number
41 stopTimestampEnabled: boolean
42 stopTimestamp: number
43 } = {} as any
44
45 private videoPlaylistId: string | number 31 private videoPlaylistId: string | number
46 private paramsSub: Subscription 32 private paramsSub: Subscription
47 private dragMoveSubject = new Subject<number>() 33 private dragMoveSubject = new Subject<number>()
@@ -124,45 +110,9 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
124 // } 110 // }
125 } 111 }
126 112
127 isVideoBlur (video: Video) { 113 onElementRemoved (video: Video) {
128 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig()) 114 this.videos = this.videos.filter(v => v.id !== video.id)
129 } 115 this.reorderClientPositions()
130
131 removeFromPlaylist (video: Video) {
132 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id)
133 .subscribe(
134 () => {
135 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
136
137 this.videos = this.videos.filter(v => v.id !== video.id)
138 this.reorderClientPositions()
139 },
140
141 err => this.notifier.error(err.message)
142 )
143
144 this.moreDropdown.close()
145 }
146
147 updateTimestamps (video: Video) {
148 const body: VideoPlaylistElementUpdate = {}
149
150 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
151 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
152
153 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body)
154 .subscribe(
155 () => {
156 this.notifier.success(this.i18n('Timestamps updated'))
157
158 video.playlistElement.startTimestamp = body.startTimestamp
159 video.playlistElement.stopTimestamp = body.stopTimestamp
160 },
161
162 err => this.notifier.error(err.message)
163 )
164
165 this.moreDropdown.close()
166 } 116 }
167 117
168 onNearOfBottom () { 118 onNearOfBottom () {
@@ -173,50 +123,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
173 this.loadElements() 123 this.loadElements()
174 } 124 }
175 125
176 formatTimestamp (video: Video) {
177 const start = video.playlistElement.startTimestamp
178 const stop = video.playlistElement.stopTimestamp
179
180 const startFormatted = secondsToTime(start, true, ':')
181 const stopFormatted = secondsToTime(stop, true, ':')
182
183 if (start === null && stop === null) return ''
184
185 if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
186 if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
187
188 return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
189 }
190
191 onDropdownOpenChange () {
192 this.displayTimestampOptions = false
193 }
194
195 toggleDisplayTimestampsOptions (event: Event, video: Video) {
196 event.preventDefault()
197
198 this.displayTimestampOptions = !this.displayTimestampOptions
199
200 if (this.displayTimestampOptions === true) {
201 this.timestampOptions = {
202 startTimestampEnabled: false,
203 stopTimestampEnabled: false,
204 startTimestamp: 0,
205 stopTimestamp: video.duration
206 }
207
208 if (video.playlistElement.startTimestamp) {
209 this.timestampOptions.startTimestampEnabled = true
210 this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp
211 }
212
213 if (video.playlistElement.stopTimestamp) {
214 this.timestampOptions.stopTimestampEnabled = true
215 this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp
216 }
217 }
218 }
219
220 private loadElements () { 126 private loadElements () {
221 this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination) 127 this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
222 .subscribe(({ totalVideos, videos }) => { 128 .subscribe(({ totalVideos, videos }) => {
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index 093e88033..3fa6fea96 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -27,7 +27,8 @@ const icons = {
27 'more-vertical': require('../../../assets/images/global/more-vertical.html'), 27 'more-vertical': require('../../../assets/images/global/more-vertical.html'),
28 'share': require('../../../assets/images/video/share.html'), 28 'share': require('../../../assets/images/video/share.html'),
29 'upload': require('../../../assets/images/video/upload.html'), 29 'upload': require('../../../assets/images/video/upload.html'),
30 'playlist-add': require('../../../assets/images/video/playlist-add.html') 30 'playlist-add': require('../../../assets/images/video/playlist-add.html'),
31 'play': require('../../../assets/images/global/play.html')
31} 32}
32 33
33export type GlobalIconName = keyof typeof icons 34export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 05da0d829..3647fc786 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -77,6 +77,7 @@ import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
77import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' 77import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
78import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' 78import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
79import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' 79import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
80import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
80 81
81@NgModule({ 82@NgModule({
82 imports: [ 83 imports: [
@@ -105,6 +106,7 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo
105 VideoMiniatureComponent, 106 VideoMiniatureComponent,
106 VideoPlaylistMiniatureComponent, 107 VideoPlaylistMiniatureComponent,
107 VideoAddToPlaylistComponent, 108 VideoAddToPlaylistComponent,
109 VideoPlaylistElementMiniatureComponent,
108 110
109 FeedComponent, 111 FeedComponent,
110 112
@@ -163,6 +165,7 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo
163 VideoMiniatureComponent, 165 VideoMiniatureComponent,
164 VideoPlaylistMiniatureComponent, 166 VideoPlaylistMiniatureComponent,
165 VideoAddToPlaylistComponent, 167 VideoAddToPlaylistComponent,
168 VideoPlaylistElementMiniatureComponent,
166 169
167 FeedComponent, 170 FeedComponent,
168 171
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
new file mode 100644
index 000000000..1f178675f
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
@@ -0,0 +1,73 @@
1<div class="video" [ngClass]="{ playing: playing }">
2 <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
3 <div class="position">
4 <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
5 <ng-container *ngIf="!playing">{{ video.playlistElement.position }}</ng-container>
6 </div>
7
8 <my-video-thumbnail
9 [video]="video" [nsfw]="isVideoBlur(video)"
10 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
11 ></my-video-thumbnail>
12
13 <div class="video-info">
14 <a tabindex="-1" class="video-info-name"
15 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
16 [attr.title]="video.name"
17 >{{ video.name }}</a>
18
19 <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
20 <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ video.byAccount }}</span>
21
22 <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video)}}</span>
23 </div>
24 </a>
25
26 <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()"
27 autoClose="outside">
28 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
29
30 <div ngbDropdownMenu>
31 <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)">
32 <my-global-icon iconName="edit"></my-global-icon>
33 <ng-container i18n>Edit starts/stops at</ng-container>
34 </div>
35
36 <div class="timestamp-options" *ngIf="displayTimestampOptions">
37 <div>
38 <my-peertube-checkbox
39 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
40 i18n-labelText labelText="Start at"
41 ></my-peertube-checkbox>
42
43 <my-timestamp-input
44 [timestamp]="timestampOptions.startTimestamp"
45 [maxTimestamp]="video.duration"
46 [disabled]="!timestampOptions.startTimestampEnabled"
47 [(ngModel)]="timestampOptions.startTimestamp"
48 ></my-timestamp-input>
49 </div>
50
51 <div>
52 <my-peertube-checkbox
53 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
54 i18n-labelText labelText="Stop at"
55 ></my-peertube-checkbox>
56
57 <my-timestamp-input
58 [timestamp]="timestampOptions.stopTimestamp"
59 [maxTimestamp]="video.duration"
60 [disabled]="!timestampOptions.stopTimestampEnabled"
61 [(ngModel)]="timestampOptions.stopTimestamp"
62 ></my-timestamp-input>
63 </div>
64
65 <input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)">
66 </div>
67
68 <span class="dropdown-item" (click)="removeFromPlaylist(video)">
69 <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{playlist?.displayName}}</ng-container>
70 </span>
71 </div>
72 </div>
73</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
new file mode 100644
index 000000000..eb869f69a
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
@@ -0,0 +1,124 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5.video {
6 display: flex;
7 align-items: center;
8 background-color: var(--mainBackgroundColor);
9 padding: 10px;
10 border-bottom: 1px solid $separator-border-color;
11
12 &:hover {
13 background-color: rgba(0, 0, 0, 0.05);
14
15 .more {
16 display: block;
17 }
18 }
19
20 &.playing {
21 background-color: rgba(0, 0, 0, 0.02);
22 }
23
24 a {
25 @include disable-default-a-behaviour;
26
27 min-width: 0;
28 display: flex;
29 align-items: center;
30 cursor: pointer;
31 flex-grow: 1;
32
33 .position {
34 font-weight: $font-semibold;
35 margin-right: 10px;
36 color: $grey-foreground-color;
37 min-width: 20px;
38
39 my-global-icon {
40 @include apply-svg-color($grey-foreground-color);
41
42 width: 17px;
43 position: relative;
44 left: -2px;
45 }
46 }
47
48 my-video-thumbnail {
49 @include thumbnail-size-component(130px, 72px);
50
51 display: flex; // Avoids an issue with line-height that adds space below the element
52 margin-right: 10px;
53 }
54
55 .video-info {
56 display: flex;
57 flex-direction: column;
58 min-width: 0;
59
60 a {
61 color: var(--mainForegroundColor);
62 width: fit-content;
63
64 &:hover {
65 text-decoration: underline !important;
66 }
67 }
68
69 .video-info-name {
70 font-size: 18px;
71 font-weight: $font-semibold;
72
73 @include ellipsis;
74 }
75
76 .video-info-account, .video-info-timestamp {
77 color: $grey-foreground-color;
78 }
79 }
80 }
81
82 .more {
83 justify-self: flex-end;
84 margin-left: auto;
85 cursor: pointer;
86 display: none;
87
88 &.show {
89 display: block;
90 }
91
92 .icon-more {
93 @include apply-svg-color($grey-foreground-color);
94
95 display: flex;
96
97 &::after {
98 border: none;
99 }
100 }
101
102 .dropdown-item {
103 @include dropdown-with-icon-item;
104 }
105
106 .timestamp-options {
107 padding-top: 0;
108 padding-left: 35px;
109 margin-bottom: 15px;
110
111 > div {
112 display: flex;
113 align-items: center;
114 }
115
116 input {
117 @include peertube-button;
118 @include orange-button;
119
120 margin-top: 10px;
121 }
122 }
123 }
124}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
new file mode 100644
index 000000000..c0cfd855d
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
@@ -0,0 +1,149 @@
1import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'
2import { Video } from '@app/shared/video/video.model'
3import { VideoPlaylistElementUpdate } from '@shared/models'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
5import { ActivatedRoute } from '@angular/router'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoService } from '@app/shared/video/video.service'
8import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
9import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
10import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
11import { secondsToTime } from '../../../assets/player/utils'
12
13@Component({
14 selector: 'my-video-playlist-element-miniature',
15 styleUrls: [ './video-playlist-element-miniature.component.scss' ],
16 templateUrl: './video-playlist-element-miniature.component.html'
17})
18export class VideoPlaylistElementMiniatureComponent {
19 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
20
21 @Input() playlist: VideoPlaylist
22 @Input() video: Video
23 @Input() owned = false
24 @Input() playing = false
25 @Input() rowLink = false
26 @Input() accountLink = true
27
28 @Output() elementRemoved = new EventEmitter<Video>()
29
30 displayTimestampOptions = false
31
32 timestampOptions: {
33 startTimestampEnabled: boolean
34 startTimestamp: number
35 stopTimestampEnabled: boolean
36 stopTimestamp: number
37 } = {} as any
38
39 constructor (
40 private authService: AuthService,
41 private serverService: ServerService,
42 private notifier: Notifier,
43 private confirmService: ConfirmService,
44 private route: ActivatedRoute,
45 private i18n: I18n,
46 private videoService: VideoService,
47 private videoPlaylistService: VideoPlaylistService
48 ) {}
49
50 buildRouterLink () {
51 if (!this.playlist) return null
52
53 return [ '/videos/watch/playlist', this.playlist.uuid ]
54 }
55
56 buildRouterQuery () {
57 if (!this.video) return {}
58
59 return {
60 videoId: this.video.uuid,
61 start: this.video.playlistElement.startTimestamp,
62 stop: this.video.playlistElement.stopTimestamp
63 }
64 }
65
66 isVideoBlur (video: Video) {
67 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
68 }
69
70 removeFromPlaylist (video: Video) {
71 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id)
72 .subscribe(
73 () => {
74 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
75
76 this.elementRemoved.emit(this.video)
77 },
78
79 err => this.notifier.error(err.message)
80 )
81
82 this.moreDropdown.close()
83 }
84
85 updateTimestamps (video: Video) {
86 const body: VideoPlaylistElementUpdate = {}
87
88 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
89 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
90
91 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body)
92 .subscribe(
93 () => {
94 this.notifier.success(this.i18n('Timestamps updated'))
95
96 video.playlistElement.startTimestamp = body.startTimestamp
97 video.playlistElement.stopTimestamp = body.stopTimestamp
98 },
99
100 err => this.notifier.error(err.message)
101 )
102
103 this.moreDropdown.close()
104 }
105
106 formatTimestamp (video: Video) {
107 const start = video.playlistElement.startTimestamp
108 const stop = video.playlistElement.stopTimestamp
109
110 const startFormatted = secondsToTime(start, true, ':')
111 const stopFormatted = secondsToTime(stop, true, ':')
112
113 if (start === null && stop === null) return ''
114
115 if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
116 if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
117
118 return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
119 }
120
121 onDropdownOpenChange () {
122 this.displayTimestampOptions = false
123 }
124
125 toggleDisplayTimestampsOptions (event: Event, video: Video) {
126 event.preventDefault()
127
128 this.displayTimestampOptions = !this.displayTimestampOptions
129
130 if (this.displayTimestampOptions === true) {
131 this.timestampOptions = {
132 startTimestampEnabled: false,
133 stopTimestampEnabled: false,
134 startTimestamp: 0,
135 stopTimestamp: video.duration
136 }
137
138 if (video.playlistElement.startTimestamp) {
139 this.timestampOptions.startTimestampEnabled = true
140 this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp
141 }
142
143 if (video.playlistElement.stopTimestamp) {
144 this.timestampOptions.stopTimestampEnabled = true
145 this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp
146 }
147 }
148 }
149}
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
index a02e9444a..186597a3a 100644
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ b/client/src/app/shared/video/infinite-scroller.directive.ts
@@ -11,6 +11,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
11 @Input() firstLoadedPage = 1 11 @Input() firstLoadedPage = 1
12 @Input() percentLimit = 70 12 @Input() percentLimit = 70
13 @Input() autoInit = false 13 @Input() autoInit = false
14 @Input() container = document.body
14 15
15 @Output() nearOfBottom = new EventEmitter<void>() 16 @Output() nearOfBottom = new EventEmitter<void>()
16 @Output() nearOfTop = new EventEmitter<void>() 17 @Output() nearOfTop = new EventEmitter<void>()
@@ -48,7 +49,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
48 .pipe( 49 .pipe(
49 startWith(null), 50 startWith(null),
50 throttleTime(200, undefined, throttleOptions), 51 throttleTime(200, undefined, throttleOptions),
51 map(() => ({ current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight })), 52 map(() => ({ current: window.scrollY, maximumScroll: this.container.clientHeight - window.innerHeight })),
52 distinctUntilChanged((o1, o2) => o1.current === o2.current), 53 distinctUntilChanged((o1, o2) => o1.current === o2.current),
53 share() 54 share()
54 ) 55 )
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index a6757fc4a..b302ebd0f 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -1,5 +1,5 @@
1<a 1<a
2 [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" 2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [attr.title]="video.name"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index ca43700c7..fe65ade94 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -10,8 +10,11 @@ import { ScreenService } from '@app/shared/misc/screen.service'
10export class VideoThumbnailComponent { 10export class VideoThumbnailComponent {
11 @Input() video: Video 11 @Input() video: Video
12 @Input() nsfw = false 12 @Input() nsfw = false
13 @Input() routerLink: any[]
14 @Input() queryParams: any[]
13 15
14 constructor (private screenService: ScreenService) {} 16 constructor (private screenService: ScreenService) {
17 }
15 18
16 getImageUrl () { 19 getImageUrl () {
17 if (!this.video) return '' 20 if (!this.video) return ''
@@ -30,4 +33,10 @@ export class VideoThumbnailComponent {
30 33
31 return (currentTime / this.video.duration) * 100 34 return (currentTime / this.video.duration) * 100
32 } 35 }
36
37 getVideoRouterLink () {
38 if (this.routerLink) return this.routerLink
39
40 return [ '/videos/watch', this.video.uuid ]
41 }
33} 42}
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 0d7809044..ce9250bdc 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,16 +7,16 @@ import { VideoWatchComponent } from './video-watch.component'
7 7
8const videoWatchRoutes: Routes = [ 8const videoWatchRoutes: Routes = [
9 { 9 {
10 path: 'playlist/:uuid', 10 path: 'playlist/:playlistId',
11 component: VideoWatchComponent, 11 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ] 12 canActivate: [ MetaGuard ]
13 }, 13 },
14 { 14 {
15 path: ':uuid/comments/:commentId', 15 path: ':videoId/comments/:commentId',
16 redirectTo: ':uuid' 16 redirectTo: ':videoId'
17 }, 17 },
18 { 18 {
19 path: ':uuid', 19 path: ':videoId',
20 component: VideoWatchComponent, 20 component: VideoWatchComponent,
21 canActivate: [ MetaGuard ] 21 canActivate: [ MetaGuard ]
22 } 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 394c31f23..7f3d1cc2e 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -1,11 +1,39 @@
1<div class="root-row row"> 1<div class="root-row row">
2 <!-- We need the video container for videojs so we just hide it --> 2 <!-- We need the video container for videojs so we just hide it -->
3 <div id="video-element-wrapper"> 3 <div id="video-wrapper">
4 <div *ngIf="remoteServerDown" class="remote-server-down"> 4 <div *ngIf="remoteServerDown" class="remote-server-down">
5 Sorry, but this video is not available because the remote instance is not responding. 5 Sorry, but this video is not available because the remote instance is not responding.
6 <br /> 6 <br />
7 Please try again later. 7 Please try again later.
8 </div> 8 </div>
9
10 <div id="videojs-wrapper"></div>
11
12 <div *ngIf="playlist && video" class="playlist">
13 <div class="playlist-info">
14 <div class="playlist-display-name">
15 {{ playlist.displayName }}
16
17 <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span>
18 <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span>
19 <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span>
20 </div>
21
22 <div class="playlist-by-index">
23 <div class="playlist-by">{{ playlist.ownerBy }}</div>
24 <div class="playlist-index">
25 <span>{{currentPlaylistPosition}}</span><span>{{playlistPagination.totalItems}}</span>
26 </div>
27 </div>
28 </div>
29
30 <div *ngFor="let playlistVideo of playlistVideos" myInfiniteScroller [autoInit]="true" #elem [container]="elem" (nearOfBottom)="onPlaylistVideosNearOfBottom()">
31 <my-video-playlist-element-miniature
32 [video]="playlistVideo" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
33 [playing]="currentPlaylistPosition === playlistVideo.playlistElement.position" [accountLink]="false"
34 ></my-video-playlist-element-miniature>
35 </div>
36 </div>
9 </div> 37 </div>
10 38
11 <div i18n class="alert alert-warning" *ngIf="isVideoToImport()"> 39 <div i18n class="alert alert-warning" *ngIf="isVideoToImport()">
@@ -20,6 +48,10 @@
20 This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. 48 This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
21 </div> 49 </div>
22 50
51 <div i18n class="alert alert-info" *ngIf="noPlaylistVideos">
52 This playlist does not have videos.
53 </div>
54
23 <div class="alert alert-danger" *ngIf="video?.blacklisted"> 55 <div class="alert alert-danger" *ngIf="video?.blacklisted">
24 <div class="blacklisted-label" i18n>This video is blacklisted.</div> 56 <div class="blacklisted-label" i18n>This video is blacklisted.</div>
25 {{ video.blacklistedReason }} 57 {{ video.blacklistedReason }}
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 44040e90d..e1cb249ef 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -1,6 +1,7 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3@import '_bootstrap-variables'; 3@import '_bootstrap-variables';
4@import '_miniature';
4 5
5$other-videos-width: 260px; 6$other-videos-width: 260px;
6 7
@@ -12,7 +13,7 @@ $other-videos-width: 260px;
12 font-weight: $font-semibold; 13 font-weight: $font-semibold;
13} 14}
14 15
15#video-element-wrapper { 16#video-wrapper {
16 background-color: #000; 17 background-color: #000;
17 display: flex; 18 display: flex;
18 justify-content: center; 19 justify-content: center;
@@ -39,6 +40,57 @@ $other-videos-width: 260px;
39 } 40 }
40 } 41 }
41 42
43 .playlist {
44 width: 400px;
45 height: 66vh;
46 background-color: #e4e4e4;
47 overflow-y: auto;
48
49 .playlist-info {
50 padding: 5px 30px;
51
52 .playlist-display-name {
53 font-size: 18px;
54 font-weight: $font-semibold;
55 margin-bottom: 5px;
56 }
57
58 .playlist-by-index {
59 color: $grey-foreground-color;
60 display: flex;
61
62 .playlist-by {
63 margin-right: 5px;
64 }
65
66 .playlist-index span:first-child::after {
67 content: '/';
68 margin: 0 3px;
69 }
70 }
71 }
72
73 my-video-playlist-element-miniature {
74 /deep/ {
75 .video {
76 .position {
77 margin-right: 0;
78 }
79
80 .video-info {
81 .video-info-name {
82 font-size: 15px;
83 }
84 }
85 }
86
87 my-video-thumbnail {
88 @include thumbnail-size-component(90px, 50px);
89 }
90 }
91 }
92 }
93
42 /deep/ .video-js { 94 /deep/ .video-js {
43 width: calc(66vh * 1.77); 95 width: calc(66vh * 1.77);
44 height: 66vh; 96 height: 66vh;
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 359217f3b..ddd0f1766 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -8,7 +8,7 @@ import { MetaService } from '@ngx-meta/core'
8import { Notifier, ServerService } from '@app/core' 8import { Notifier, ServerService } from '@app/core'
9import { forkJoin, Subscription } from 'rxjs' 9import { forkJoin, Subscription } from 'rxjs'
10import { Hotkey, HotkeysService } from 'angular2-hotkeys' 10import { Hotkey, HotkeysService } from 'angular2-hotkeys'
11import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' 11import { UserVideoRateType, VideoCaption, VideoPlaylistPrivacy, VideoPrivacy, VideoState } from '../../../../../shared'
12import { AuthService, ConfirmService } from '../../core' 12import { AuthService, ConfirmService } from '../../core'
13import { RestExtractor, VideoBlacklistService } from '../../shared' 13import { RestExtractor, VideoBlacklistService } from '../../shared'
14import { VideoDetails } from '../../shared/video/video-details.model' 14import { VideoDetails } from '../../shared/video/video-details.model'
@@ -28,6 +28,10 @@ import {
28 PeertubePlayerManagerOptions, 28 PeertubePlayerManagerOptions,
29 PlayerMode 29 PlayerMode
30} from '../../../assets/player/peertube-player-manager' 30} from '../../../assets/player/peertube-player-manager'
31import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
32import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
33import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
34import { Video } from '@app/shared/video/video.model'
31 35
32@Component({ 36@Component({
33 selector: 'my-video-watch', 37 selector: 'my-video-watch',
@@ -50,6 +54,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
50 video: VideoDetails = null 54 video: VideoDetails = null
51 descriptionLoading = false 55 descriptionLoading = false
52 56
57 playlist: VideoPlaylist = null
58 playlistVideos: Video[] = []
59 playlistPagination: ComponentPagination = {
60 currentPage: 1,
61 itemsPerPage: 10,
62 totalItems: null
63 }
64 noPlaylistVideos = false
65 currentPlaylistPosition = 1
66
53 completeDescriptionShown = false 67 completeDescriptionShown = false
54 completeVideoDescription: string 68 completeVideoDescription: string
55 shortVideoDescription: string 69 shortVideoDescription: string
@@ -61,6 +75,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
61 75
62 private currentTime: number 76 private currentTime: number
63 private paramsSub: Subscription 77 private paramsSub: Subscription
78 private queryParamsSub: Subscription
64 79
65 constructor ( 80 constructor (
66 private elementRef: ElementRef, 81 private elementRef: ElementRef,
@@ -68,6 +83,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
68 private route: ActivatedRoute, 83 private route: ActivatedRoute,
69 private router: Router, 84 private router: Router,
70 private videoService: VideoService, 85 private videoService: VideoService,
86 private playlistService: VideoPlaylistService,
71 private videoBlacklistService: VideoBlacklistService, 87 private videoBlacklistService: VideoBlacklistService,
72 private confirmService: ConfirmService, 88 private confirmService: ConfirmService,
73 private metaService: MetaService, 89 private metaService: MetaService,
@@ -97,31 +113,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
97 } 113 }
98 114
99 this.paramsSub = this.route.params.subscribe(routeParams => { 115 this.paramsSub = this.route.params.subscribe(routeParams => {
100 const uuid = routeParams[ 'uuid' ] 116 const videoId = routeParams[ 'videoId' ]
117 if (videoId) this.loadVideo(videoId)
101 118
102 // Video did not change 119 const playlistId = routeParams[ 'playlistId' ]
103 if (this.video && this.video.uuid === uuid) return 120 if (playlistId) this.loadPlaylist(playlistId)
104 121 })
105 if (this.player) this.player.pause()
106 122
107 // Video did change 123 this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
108 forkJoin( 124 const videoId = queryParams[ 'videoId' ]
109 this.videoService.getVideo(uuid), 125 if (videoId) this.loadVideo(videoId)
110 this.videoCaptionService.listCaptions(uuid)
111 )
112 .pipe(
113 // If 401, the video is private or blacklisted so redirect to 404
114 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
115 )
116 .subscribe(([ video, captionsResult ]) => {
117 const startTime = this.route.snapshot.queryParams.start
118 const stopTime = this.route.snapshot.queryParams.stop
119 const subtitle = this.route.snapshot.queryParams.subtitle
120 const playerMode = this.route.snapshot.queryParams.mode
121
122 this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
123 .catch(err => this.handleError(err))
124 })
125 }) 126 })
126 127
127 this.hotkeys = [ 128 this.hotkeys = [
@@ -147,7 +148,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
147 this.flushPlayer() 148 this.flushPlayer()
148 149
149 // Unsubscribe subscriptions 150 // Unsubscribe subscriptions
150 this.paramsSub.unsubscribe() 151 if (this.paramsSub) this.paramsSub.unsubscribe()
152 if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
151 153
152 // Unbind hotkeys 154 // Unbind hotkeys
153 if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) 155 if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
@@ -219,8 +221,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
219 } 221 }
220 222
221 showShareModal () { 223 showShareModal () {
222 const currentTime = this.player ? this.player.currentTime() : undefined
223
224 this.videoShareModal.show(this.currentTime) 224 this.videoShareModal.show(this.currentTime)
225 } 225 }
226 226
@@ -322,6 +322,107 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
322 return this.video && this.video.scheduledUpdate !== undefined 322 return this.video && this.video.scheduledUpdate !== undefined
323 } 323 }
324 324
325 isVideoBlur (video: Video) {
326 return video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
327 }
328
329 isPlaylistOwned () {
330 return this.playlist.isLocal === true && this.playlist.ownerAccount.name === this.user.username
331 }
332
333 isUnlistedPlaylist () {
334 return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
335 }
336
337 isPrivatePlaylist () {
338 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
339 }
340
341 isPublicPlaylist () {
342 return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
343 }
344
345 onPlaylistVideosNearOfBottom () {
346 // Last page
347 if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
348
349 this.playlistPagination.currentPage += 1
350 this.loadPlaylistElements(false)
351 }
352
353 onElementRemoved (video: Video) {
354 this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id)
355
356 this.playlistPagination.totalItems--
357 }
358
359 private loadVideo (videoId: string) {
360 // Video did not change
361 if (this.video && this.video.uuid === videoId) return
362
363 if (this.player) this.player.pause()
364
365 // Video did change
366 forkJoin(
367 this.videoService.getVideo(videoId),
368 this.videoCaptionService.listCaptions(videoId)
369 )
370 .pipe(
371 // If 401, the video is private or blacklisted so redirect to 404
372 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
373 )
374 .subscribe(([ video, captionsResult ]) => {
375 const queryParams = this.route.snapshot.queryParams
376 const startTime = queryParams.start
377 const stopTime = queryParams.stop
378 const subtitle = queryParams.subtitle
379 const playerMode = queryParams.mode
380
381 this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
382 .catch(err => this.handleError(err))
383 })
384 }
385
386 private loadPlaylist (playlistId: string) {
387 // Playlist did not change
388 if (this.playlist && this.playlist.uuid === playlistId) return
389
390 this.playlistService.getVideoPlaylist(playlistId)
391 .pipe(
392 // If 401, the video is private or blacklisted so redirect to 404
393 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
394 )
395 .subscribe(playlist => {
396 this.playlist = playlist
397
398 const videoId = this.route.snapshot.queryParams['videoId']
399 this.loadPlaylistElements(!videoId)
400 })
401 }
402
403 private loadPlaylistElements (redirectToFirst = false) {
404 this.videoService.getPlaylistVideos(this.playlist.id, this.playlistPagination)
405 .subscribe(({ totalVideos, videos }) => {
406 this.playlistVideos = this.playlistVideos.concat(videos)
407 this.playlistPagination.totalItems = totalVideos
408
409 if (totalVideos === 0) {
410 this.noPlaylistVideos = true
411 return
412 }
413
414 this.updatePlaylistIndex()
415
416 if (redirectToFirst) {
417 const extras = {
418 queryParams: { videoId: this.playlistVideos[ 0 ].uuid },
419 replaceUrl: true
420 }
421 this.router.navigate([], extras)
422 }
423 })
424 }
425
325 private updateVideoDescription (description: string) { 426 private updateVideoDescription (description: string) {
326 this.video.description = description 427 this.video.description = description
327 this.setVideoDescriptionHTML() 428 this.setVideoDescriptionHTML()
@@ -383,11 +484,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
383 this.remoteServerDown = false 484 this.remoteServerDown = false
384 this.currentTime = undefined 485 this.currentTime = undefined
385 486
487 this.updatePlaylistIndex()
488
386 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) 489 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
387 // If we are at the end of the video, reset the timer 490 // If we are at the end of the video, reset the timer
388 if (this.video.duration - startTime <= 1) startTime = 0 491 if (this.video.duration - startTime <= 1) startTime = 0
389 492
390 if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { 493 if (this.isVideoBlur(this.video)) {
391 const res = await this.confirmService.confirm( 494 const res = await this.confirmService.confirm(
392 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), 495 this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
393 this.i18n('Mature or explicit content') 496 this.i18n('Mature or explicit content')
@@ -399,7 +502,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
399 this.flushPlayer() 502 this.flushPlayer()
400 503
401 // Build video element, because videojs remove it on dispose 504 // Build video element, because videojs remove it on dispose
402 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#video-element-wrapper') 505 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
403 this.playerElement = document.createElement('video') 506 this.playerElement = document.createElement('video')
404 this.playerElement.className = 'video-js vjs-peertube-skin' 507 this.playerElement.className = 'video-js vjs-peertube-skin'
405 this.playerElement.setAttribute('playsinline', 'true') 508 this.playerElement.setAttribute('playsinline', 'true')
@@ -474,6 +577,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
474 this.player.on('timeupdate', () => { 577 this.player.on('timeupdate', () => {
475 this.currentTime = Math.floor(this.player.currentTime()) 578 this.currentTime = Math.floor(this.player.currentTime())
476 }) 579 })
580
581 this.player.one('ended', () => {
582 if (this.playlist) {
583 this.zone.run(() => this.navigateToNextPlaylistVideo())
584 }
585 })
586
587 this.player.one('stopped', () => {
588 if (this.playlist) {
589 this.zone.run(() => this.navigateToNextPlaylistVideo())
590 }
591 })
477 }) 592 })
478 593
479 this.setVideoDescriptionHTML() 594 this.setVideoDescriptionHTML()
@@ -528,6 +643,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
528 this.setVideoLikesBarTooltipText() 643 this.setVideoLikesBarTooltipText()
529 } 644 }
530 645
646 private updatePlaylistIndex () {
647 if (this.playlistVideos.length === 0 || !this.video) return
648
649 for (const video of this.playlistVideos) {
650 if (video.id === this.video.id) {
651 this.currentPlaylistPosition = video.playlistElement.position
652 return
653 }
654 }
655
656 // Load more videos to find our video
657 this.onPlaylistVideosNearOfBottom()
658 }
659
531 private setOpenGraphTags () { 660 private setOpenGraphTags () {
532 this.metaService.setTitle(this.video.name) 661 this.metaService.setTitle(this.video.name)
533 662
@@ -567,4 +696,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
567 this.player = undefined 696 this.player = undefined
568 } 697 }
569 } 698 }
699
700 private navigateToNextPlaylistVideo () {
701 if (this.currentPlaylistPosition < this.playlistPagination.totalItems) {
702 const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1)
703
704 const start = next.playlistElement.startTimestamp
705 const stop = next.playlistElement.stopTimestamp
706 this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } })
707 }
708 }
570} 709}
diff --git a/client/src/assets/images/global/play.html b/client/src/assets/images/global/play.html
new file mode 100644
index 000000000..d00122de4
--- /dev/null
+++ b/client/src/assets/images/global/play.html
@@ -0,0 +1,9 @@
1<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2 <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
3 <g id="Artboard-4" transform="translate(-532.000000, -115.000000)" stroke-width="2" stroke="#000000">
4 <g id="12" transform="translate(532.000000, 115.000000)">
5 <polygon id="Triangle-1" points="5 21 5 3 21 12" fill="#000000"/>
6 </g>
7 </g>
8 </g>
9</svg>
diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
index 4dbfda300..bbd3e008d 100644
--- a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
+++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
@@ -4,6 +4,7 @@ import * as videojs from 'video.js'
4import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings' 4import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings'
5import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' 5import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
6import { Events } from 'p2p-media-loader-core' 6import { Events } from 'p2p-media-loader-core'
7import { timeToInt } from '../utils'
7 8
8// videojs-hlsjs-plugin needs videojs in window 9// videojs-hlsjs-plugin needs videojs in window
9window['videojs'] = videojs 10window['videojs'] = videojs
@@ -32,6 +33,7 @@ class P2pMediaLoaderPlugin extends Plugin {
32 totalDownload: 0, 33 totalDownload: 0,
33 totalUpload: 0 34 totalUpload: 0
34 } 35 }
36 private startTime: number
35 37
36 private networkInfoInterval: any 38 private networkInfoInterval: any
37 39
@@ -54,12 +56,14 @@ class P2pMediaLoaderPlugin extends Plugin {
54 56
55 initVideoJsContribHlsJsPlayer(player) 57 initVideoJsContribHlsJsPlayer(player)
56 58
59 this.startTime = timeToInt(options.startTime)
60
57 player.src({ 61 player.src({
58 type: options.type, 62 type: options.type,
59 src: options.src 63 src: options.src
60 }) 64 })
61 65
62 player.on('play', () => { 66 player.one('play', () => {
63 player.addClass('vjs-has-big-play-button-clicked') 67 player.addClass('vjs-has-big-play-button-clicked')
64 }) 68 })
65 69
@@ -92,6 +96,12 @@ class P2pMediaLoaderPlugin extends Plugin {
92 this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length 96 this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length
93 97
94 this.runStats() 98 this.runStats()
99
100 this.hlsjs.on('hlsLevelLoaded', () => {
101 if (this.startTime) this.player.currentTime(this.startTime)
102
103 this.hlsjs.off('hlsLevelLoaded', this)
104 })
95 } 105 }
96 106
97 private runStats () { 107 private runStats () {
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts
index 3991e4627..dd9408c8e 100644
--- a/client/src/assets/player/peertube-plugin.ts
+++ b/client/src/assets/player/peertube-plugin.ts
@@ -83,9 +83,15 @@ class PeerTubePlugin extends Plugin {
83 83
84 if (options.stopTime) { 84 if (options.stopTime) {
85 const stopTime = timeToInt(options.stopTime) 85 const stopTime = timeToInt(options.stopTime)
86 const self = this
86 87
87 this.player.on('timeupdate', () => { 88 this.player.on('timeupdate', function onTimeUpdate () {
88 if (this.player.currentTime() > stopTime) this.player.pause() 89 if (self.player.currentTime() > stopTime) {
90 self.player.pause()
91 self.player.trigger('stopped')
92
93 self.player.off('timeupdate', onTimeUpdate)
94 }
89 }) 95 })
90 } 96 }
91 97
diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss
index 25a024aac..95b759225 100644
--- a/client/src/sass/include/_miniature.scss
+++ b/client/src/sass/include/_miniature.scss
@@ -28,15 +28,15 @@ $play-overlay-transition: 0.2s ease;
28$play-overlay-height: 26px; 28$play-overlay-height: 26px;
29$play-overlay-width: 18px; 29$play-overlay-width: 18px;
30 30
31@mixin miniature-thumbnail($width: $video-thumbnail-width, $height: $video-thumbnail-height) { 31@mixin miniature-thumbnail {
32 @include disable-outline; 32 @include disable-outline;
33 33
34 display: inline-block; 34 display: inline-block;
35 position: relative; 35 position: relative;
36 border-radius: 3px; 36 border-radius: 3px;
37 overflow: hidden; 37 overflow: hidden;
38 width: $width; 38 width: $video-thumbnail-width;
39 height: $height; 39 height: $video-thumbnail-height;
40 background-color: #ececec; 40 background-color: #ececec;
41 transition: filter $play-overlay-transition; 41 transition: filter $play-overlay-transition;
42 42
@@ -97,6 +97,13 @@ $play-overlay-width: 18px;
97 } 97 }
98} 98}
99 99
100@mixin thumbnail-size-component ($width, $height) {
101 /deep/ .video-thumbnail {
102 width: $width;
103 height: $height;
104 }
105}
106
100@mixin static-thumbnail-overlay { 107@mixin static-thumbnail-overlay {
101 display: inline-block; 108 display: inline-block;
102 background-color: rgba(0, 0, 0, 0.7); 109 background-color: rgba(0, 0, 0, 0.7);
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index 7faeec6bd..9b18f6354 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -63,11 +63,11 @@
63 63
64@mixin apply-svg-color ($color) { 64@mixin apply-svg-color ($color) {
65 /deep/ svg { 65 /deep/ svg {
66 path[fill="#000000"], g[fill="#000000"], rect[fill="#000000"], circle[fill="#000000"] { 66 path[fill="#000000"], g[fill="#000000"], rect[fill="#000000"], circle[fill="#000000"], polygon[fill="#000000"] {
67 fill: $color; 67 fill: $color;
68 } 68 }
69 69
70 path[stroke="#000000"], g[stroke="#000000"], rect[stroke="#000000"], circle[stroke="#000000"] { 70 path[stroke="#000000"], g[stroke="#000000"], rect[stroke="#000000"], circle[stroke="#000000"], polygon[stroke="#000000"] {
71 stroke: $color; 71 stroke: $color;
72 } 72 }
73 } 73 }