diff options
author | Chocobozzz <me@florianbigard.com> | 2019-03-13 14:18:58 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2019-03-18 11:17:59 +0100 |
commit | e2f01c47e08d26a30ad47068d195b3d21d0df8a1 (patch) | |
tree | 21f18ed462d313bfb4ba7a1b5221fdb6b2c35bc1 /client/src/app | |
parent | 15e9d5ca39e0b792f61453fbf3885a0fc446afa7 (diff) | |
download | PeerTube-e2f01c47e08d26a30ad47068d195b3d21d0df8a1.tar.gz PeerTube-e2f01c47e08d26a30ad47068d195b3d21d0df8a1.tar.zst PeerTube-e2f01c47e08d26a30ad47068d195b3d21d0df8a1.zip |
Playlist support in watch page
Diffstat (limited to 'client/src/app')
15 files changed, 628 insertions, 286 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 @@ | |||
1 | import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { Notifier, ServerService } from '@app/core' | 2 | import { Notifier, ServerService } from '@app/core' |
3 | import { AuthService } from '../../core/auth' | 3 | import { AuthService } from '../../core/auth' |
4 | import { ConfirmService } from '../../core/confirm' | 4 | import { ConfirmService } from '../../core/confirm' |
@@ -10,9 +10,6 @@ import { VideoService } from '@app/shared/video/video.service' | |||
10 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | 10 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' |
11 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | 11 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' |
12 | import { I18n } from '@ngx-translate/i18n-polyfill' | 12 | import { I18n } from '@ngx-translate/i18n-polyfill' |
13 | import { secondsToTime } from '../../../assets/player/utils' | ||
14 | import { VideoPlaylistElementUpdate } from '@shared/models' | ||
15 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
16 | import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' | 13 | import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' |
17 | import { throttleTime } from 'rxjs/operators' | 14 | import { 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 | }) |
24 | export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy { | 21 | export 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 | ||
33 | export type GlobalIconName = keyof typeof icons | 34 | 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 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' | |||
77 | import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' | 77 | import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' |
78 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' | 78 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' |
79 | import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' | 79 | import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' |
80 | import { 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 @@ | |||
1 | import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' | ||
2 | import { Video } from '@app/shared/video/video.model' | ||
3 | import { VideoPlaylistElementUpdate } from '@shared/models' | ||
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoService } from '@app/shared/video/video.service' | ||
8 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
9 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
10 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
11 | import { 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 | }) | ||
18 | export 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' | |||
10 | export class VideoThumbnailComponent { | 10 | export 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 | ||
8 | const videoWatchRoutes: Routes = [ | 8 | const 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' | |||
8 | import { Notifier, ServerService } from '@app/core' | 8 | import { Notifier, ServerService } from '@app/core' |
9 | import { forkJoin, Subscription } from 'rxjs' | 9 | import { forkJoin, Subscription } from 'rxjs' |
10 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 10 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
11 | import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' | 11 | import { UserVideoRateType, VideoCaption, VideoPlaylistPrivacy, VideoPrivacy, VideoState } from '../../../../../shared' |
12 | import { AuthService, ConfirmService } from '../../core' | 12 | import { AuthService, ConfirmService } from '../../core' |
13 | import { RestExtractor, VideoBlacklistService } from '../../shared' | 13 | import { RestExtractor, VideoBlacklistService } from '../../shared' |
14 | import { VideoDetails } from '../../shared/video/video-details.model' | 14 | import { 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' |
31 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
32 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
33 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
34 | import { 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 | } |