aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.scss3
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/index.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html54
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.scss40
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts25
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts2
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html57
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.scss41
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts400
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.module.ts4
10 files changed, 311 insertions, 316 deletions
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.scss b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.scss
index 109c31c57..7b8a876ab 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.scss
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.scss
@@ -1,3 +1,6 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
1.alert { 4.alert {
2 text-align: center; 5 text-align: center;
3 border-radius: 0; 6 border-radius: 0;
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/index.ts b/client/src/app/+videos/+video-watch/shared/metadata/index.ts
index 7f7ee797b..de9abe97e 100644
--- a/client/src/app/+videos/+video-watch/shared/metadata/index.ts
+++ b/client/src/app/+videos/+video-watch/shared/metadata/index.ts
@@ -1,2 +1,3 @@
1export * from './video-attributes.component'
1export * from './video-avatar-channel.component' 2export * from './video-avatar-channel.component'
2export * from './video-description.component' 3export * from './video-description.component'
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html
new file mode 100644
index 000000000..598bc485d
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.html
@@ -0,0 +1,54 @@
1<div class="video-attribute">
2 <span i18n class="video-attribute-label">Privacy</span>
3 <span class="video-attribute-value">{{ video.privacy.label }}</span>
4</div>
5
6<div *ngIf="video.isLocal === false" class="video-attribute">
7 <span i18n class="video-attribute-label">Origin</span>
8 <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="getVideoUrl()">{{ video.originInstanceHost }}</a>
9</div>
10
11<div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
12 <span i18n class="video-attribute-label">Originally published</span>
13 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
14</div>
15
16<div class="video-attribute">
17 <span i18n class="video-attribute-label">Category</span>
18 <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span>
19 <a
20 *ngIf="video.category.id" class="video-attribute-value"
21 [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }"
22 >{{ video.category.label }}</a>
23</div>
24
25<div class="video-attribute">
26 <span i18n class="video-attribute-label">Licence</span>
27 <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span>
28 <a
29 *ngIf="video.licence.id" class="video-attribute-value"
30 [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }"
31 >{{ video.licence.label }}</a>
32</div>
33
34<div class="video-attribute">
35 <span i18n class="video-attribute-label">Language</span>
36 <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span>
37 <a
38 *ngIf="video.language.id" class="video-attribute-value"
39 [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }"
40 >{{ video.language.label }}</a>
41</div>
42
43<div class="video-attribute video-attribute-tags">
44 <span i18n class="video-attribute-label">Tags</span>
45 <a
46 *ngFor="let tag of getVideoTags()"
47 class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }"
48 >{{ tag }}</a>
49</div>
50
51<div class="video-attribute" *ngIf="!video.isLive">
52 <span i18n class="video-attribute-label">Duration</span>
53 <span class="video-attribute-value">{{ video.duration | myDurationFormatter }}</span>
54</div>
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.scss b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.scss
new file mode 100644
index 000000000..45190a3e3
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.scss
@@ -0,0 +1,40 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.video-attribute {
5 font-size: 13px;
6 display: block;
7 margin-bottom: 12px;
8}
9
10.video-attribute-label {
11 @include padding-right(5px);
12
13 min-width: 142px;
14 display: inline-block;
15 color: pvar(--greyForegroundColor);
16 font-weight: $font-bold;
17}
18
19a.video-attribute-value {
20 @include disable-default-a-behaviour;
21 color: pvar(--mainForegroundColor);
22
23 &:hover {
24 opacity: 0.9;
25 }
26}
27
28.video-attribute-tags {
29 .video-attribute-value:not(:nth-child(2)) {
30 &::before {
31 content: ', ';
32 }
33 }
34}
35
36@media screen and (max-width: 1600px) {
37 .video-attributes .video-attribute {
38 margin-bottom: 5px;
39 }
40}
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts
new file mode 100644
index 000000000..5cb77f0c8
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-attributes.component.ts
@@ -0,0 +1,25 @@
1import { Component, Input } from '@angular/core'
2import { VideoDetails } from '@app/shared/shared-main'
3
4@Component({
5 selector: 'my-video-attributes',
6 templateUrl: './video-attributes.component.html',
7 styleUrls: [ './video-attributes.component.scss' ]
8})
9export class VideoAttributesComponent {
10 @Input() video: VideoDetails
11
12 getVideoUrl () {
13 if (!this.video.url) {
14 return this.video.originInstanceUrl + VideoDetails.buildWatchUrl(this.video)
15 }
16
17 return this.video.url
18 }
19
20 getVideoTags () {
21 if (!this.video || Array.isArray(this.video.tags) === false) return []
22
23 return this.video.tags
24 }
25}
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
index 0a4d6bfd1..8b3ed4964 100644
--- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
@@ -130,7 +130,7 @@ export class VideoWatchPlaylistComponent {
130 130
131 setTimeout(() => { 131 setTimeout(() => {
132 document.querySelector('.element-' + this.currentPlaylistPosition).scrollIntoView(false) 132 document.querySelector('.element-' + this.currentPlaylistPosition).scrollIntoView(false)
133 }, 0) 133 })
134 134
135 return 135 return
136 } 136 }
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 a0508731f..0d6103b3b 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.html
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -88,62 +88,7 @@
88 88
89 <my-video-description [video]="video"></my-video-description> 89 <my-video-description [video]="video"></my-video-description>
90 90
91 <div class="video-attributes mb-3"> 91 <my-video-attributes [video]="video"></my-video-attributes>
92 <div class="video-attribute">
93 <span i18n class="video-attribute-label">Privacy</span>
94 <span class="video-attribute-value">{{ video.privacy.label }}</span>
95 </div>
96
97 <div *ngIf="video.isLocal === false" class="video-attribute">
98 <span i18n class="video-attribute-label">Origin</span>
99 <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="getVideoUrl()">{{ video.originInstanceHost }}</a>
100 </div>
101
102 <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
103 <span i18n class="video-attribute-label">Originally published</span>
104 <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
105 </div>
106
107 <div class="video-attribute">
108 <span i18n class="video-attribute-label">Category</span>
109 <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span>
110 <a
111 *ngIf="video.category.id" class="video-attribute-value"
112 [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }"
113 >{{ video.category.label }}</a>
114 </div>
115
116 <div class="video-attribute">
117 <span i18n class="video-attribute-label">Licence</span>
118 <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span>
119 <a
120 *ngIf="video.licence.id" class="video-attribute-value"
121 [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }"
122 >{{ video.licence.label }}</a>
123 </div>
124
125 <div class="video-attribute">
126 <span i18n class="video-attribute-label">Language</span>
127 <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span>
128 <a
129 *ngIf="video.language.id" class="video-attribute-value"
130 [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }"
131 >{{ video.language.label }}</a>
132 </div>
133
134 <div class="video-attribute video-attribute-tags">
135 <span i18n class="video-attribute-label">Tags</span>
136 <a
137 *ngFor="let tag of getVideoTags()"
138 class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }"
139 >{{ tag }}</a>
140 </div>
141
142 <div class="video-attribute" *ngIf="!video.isLive">
143 <span i18n class="video-attribute-label">Duration</span>
144 <span class="video-attribute-value">{{ video.duration | myDurationFormatter }}</span>
145 </div>
146 </div>
147 92
148 <my-video-comments 93 <my-video-comments
149 class="border-top" 94 class="border-top"
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 2fc847716..abad686ff 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/+videos/+video-watch/video-watch.component.scss
@@ -199,40 +199,11 @@ my-subscribe-button {
199 @include margin-left(5px); 199 @include margin-left(5px);
200} 200}
201 201
202.video-attributes { 202my-video-attributes {
203 @include margin-left($video-watch-info-margin-left); 203 @include margin-left($video-watch-info-margin-left);
204 204
205 .video-attribute { 205 display: block;
206 font-size: 13px; 206 margin-bottom: 15px;
207 display: block;
208 margin-bottom: 12px;
209 }
210}
211
212.video-attribute-label {
213 @include padding-right(5px);
214
215 min-width: 142px;
216 display: inline-block;
217 color: pvar(--greyForegroundColor);
218 font-weight: $font-bold;
219}
220
221a.video-attribute-value {
222 @include disable-default-a-behaviour;
223 color: pvar(--mainForegroundColor);
224
225 &:hover {
226 opacity: 0.9;
227 }
228}
229
230.video-attribute-tags {
231 .video-attribute-value:not(:nth-child(2)) {
232 &::before {
233 content: ', ';
234 }
235 }
236} 207}
237 208
238my-action-buttons { 209my-action-buttons {
@@ -260,12 +231,6 @@ my-video-comments {
260 margin-bottom: 20px; 231 margin-bottom: 20px;
261} 232}
262 233
263@media screen and (max-width: 1600px) {
264 .video-attributes .video-attribute {
265 margin-bottom: 5px;
266 }
267}
268
269// Use the same breakpoint than in the typescript component to display the other video miniatures as row 234// Use the same breakpoint than in the typescript component to display the other video miniatures as row
270@media screen and (max-width: 1100px) { 235@media screen and (max-width: 1100px) {
271 #video-wrapper { 236 #video-wrapper {
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 7af37ef03..85b882225 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -1,8 +1,7 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { forkJoin, Subscription } from 'rxjs' 2import { forkJoin, Subscription } from 'rxjs'
3import { catchError } from 'rxjs/operators'
4import { PlatformLocation } from '@angular/common' 3import { PlatformLocation } from '@angular/common'
5import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
6import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
7import { 6import {
8 AuthService, 7 AuthService,
@@ -21,7 +20,6 @@ import { HooksService } from '@app/core/plugins/hooks.service'
21import { isXPercentInViewport, scrollToTop } from '@app/helpers' 20import { isXPercentInViewport, scrollToTop } from '@app/helpers'
22import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' 21import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
23import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' 22import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
24import { VideoActionsDisplayType } from '@app/shared/shared-video-miniature'
25import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 23import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
26import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' 24import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
27import { HTMLServerConfig, PeerTubeProblemDocument, ServerErrorCode, VideoCaption, VideoPrivacy, VideoState } from '@shared/models' 25import { HTMLServerConfig, PeerTubeProblemDocument, ServerErrorCode, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
@@ -51,10 +49,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
51 49
52 player: any 50 player: any
53 playerElement: HTMLVideoElement 51 playerElement: HTMLVideoElement
54
55 theaterEnabled = false
56
57 playerPlaceholderImgSrc: string 52 playerPlaceholderImgSrc: string
53 theaterEnabled = false
58 54
59 video: VideoDetails = null 55 video: VideoDetails = null
60 videoCaptions: VideoCaption[] = [] 56 videoCaptions: VideoCaption[] = []
@@ -62,28 +58,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
62 playlistPosition: number 58 playlistPosition: number
63 playlist: VideoPlaylist = null 59 playlist: VideoPlaylist = null
64 60
65 likesBarTooltipText = ''
66
67 remoteServerDown = false 61 remoteServerDown = false
68 62
69 tooltipSupport = '' 63 private nextVideoUUID = ''
70 tooltipSaveToPlaylist = ''
71
72 videoActionsOptions: VideoActionsDisplayType = {
73 playlist: false,
74 download: true,
75 update: true,
76 blacklist: true,
77 delete: true,
78 report: true,
79 duplicate: true,
80 mute: true,
81 liveInfo: true
82 }
83
84 private nextVideoUuid = ''
85 private nextVideoTitle = '' 64 private nextVideoTitle = ''
65
86 private currentTime: number 66 private currentTime: number
67
87 private paramsSub: Subscription 68 private paramsSub: Subscription
88 private queryParamsSub: Subscription 69 private queryParamsSub: Subscription
89 private configSub: Subscription 70 private configSub: Subscription
@@ -95,7 +76,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
95 76
96 constructor ( 77 constructor (
97 private elementRef: ElementRef, 78 private elementRef: ElementRef,
98 private changeDetector: ChangeDetectorRef,
99 private route: ActivatedRoute, 79 private route: ActivatedRoute,
100 private router: Router, 80 private router: Router,
101 private videoService: VideoService, 81 private videoService: VideoService,
@@ -129,40 +109,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
129 async ngOnInit () { 109 async ngOnInit () {
130 this.serverConfig = this.serverService.getHTMLConfig() 110 this.serverConfig = this.serverService.getHTMLConfig()
131 111
132 // Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover
133 if (this.user || !this.screenService.isInMobileView()) {
134 this.tooltipSupport = $localize`Support options for this video`
135 this.tooltipSaveToPlaylist = $localize`Save to playlist`
136 }
137
138 PeertubePlayerManager.initState() 112 PeertubePlayerManager.initState()
139 113
140 this.paramsSub = this.route.params.subscribe(routeParams => { 114 this.loadRouteParams()
141 const videoId = routeParams[ 'videoId' ] 115 this.loadRouteQuery()
142 if (videoId) this.loadVideo(videoId)
143
144 const playlistId = routeParams[ 'playlistId' ]
145 if (playlistId) this.loadPlaylist(playlistId)
146 })
147
148 this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
149 // Handle the ?playlistPosition
150 const positionParam = queryParams[ 'playlistPosition' ] ?? 1
151
152 this.playlistPosition = positionParam === 'last'
153 ? -1 // Handle the "last" index
154 : parseInt(positionParam + '', 10)
155
156 if (isNaN(this.playlistPosition)) {
157 console.error(`playlistPosition query param '${positionParam}' was parsed as NaN, defaulting to 1.`)
158 this.playlistPosition = 1
159 }
160
161 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
162
163 const start = queryParams[ 'start' ]
164 if (this.player && start) this.player.currentTime(parseInt(start, 10))
165 })
166 116
167 this.initHotkeys() 117 this.initHotkeys()
168 118
@@ -194,41 +144,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
194 return this.videoWatchPlaylist.currentPlaylistPosition 144 return this.videoWatchPlaylist.currentPlaylistPosition
195 } 145 }
196 146
197 isUserLoggedIn () {
198 return this.authService.isLoggedIn()
199 }
200
201 getVideoUrl () {
202 if (!this.video.url) {
203 return this.video.originInstanceUrl + VideoDetails.buildWatchUrl(this.video)
204 }
205 return this.video.url
206 }
207
208 getVideoTags () {
209 if (!this.video || Array.isArray(this.video.tags) === false) return []
210
211 return this.video.tags
212 }
213
214 onRecommendations (videos: Video[]) { 147 onRecommendations (videos: Video[]) {
215 if (videos.length > 0) { 148 if (videos.length === 0) return
216 // The recommended videos's first element should be the next video
217 const video = videos[0]
218 this.nextVideoUuid = video.uuid
219 this.nextVideoTitle = video.name
220 }
221 }
222 149
223 isVideoBlur (video: Video) { 150 // The recommended videos's first element should be the next video
224 return video.isVideoNSFWForUser(this.user, this.serverConfig) 151 const video = videos[0]
225 } 152 this.nextVideoUUID = video.uuid
226 153 this.nextVideoTitle = video.name
227 isAutoPlayEnabled () {
228 return (
229 (this.user && this.user.autoPlayNextVideo) ||
230 this.anonymousUser.autoPlayNextVideo
231 )
232 } 154 }
233 155
234 handleTimestampClicked (timestamp: number) { 156 handleTimestampClicked (timestamp: number) {
@@ -238,11 +160,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
238 scrollToTop() 160 scrollToTop()
239 } 161 }
240 162
241 isPlaylistAutoPlayEnabled () { 163 onPlaylistVideoFound (videoId: string) {
242 return ( 164 this.loadVideo(videoId)
243 (this.user && this.user.autoPlayNextVideoPlaylist) || 165 }
244 this.anonymousUser.autoPlayNextVideoPlaylist 166
245 ) 167 isUserLoggedIn () {
168 return this.authService.isLoggedIn()
169 }
170
171 isVideoBlur (video: Video) {
172 return video.isVideoNSFWForUser(this.user, this.serverConfig)
246 } 173 }
247 174
248 isChannelDisplayNameGeneric () { 175 isChannelDisplayNameGeneric () {
@@ -254,21 +181,44 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
254 return genericChannelDisplayName.includes(this.video.channel.displayName) 181 return genericChannelDisplayName.includes(this.video.channel.displayName)
255 } 182 }
256 183
257 onPlaylistVideoFound (videoId: string) {
258 this.loadVideo(videoId)
259 }
260
261 displayOtherVideosAsRow () { 184 displayOtherVideosAsRow () {
262 // Use the same value as in the SASS file 185 // Use the same value as in the SASS file
263 return this.screenService.getWindowInnerWidth() <= 1100 186 return this.screenService.getWindowInnerWidth() <= 1100
264 } 187 }
265 188
189 private loadRouteParams () {
190 this.paramsSub = this.route.params.subscribe(routeParams => {
191 const videoId = routeParams[ 'videoId' ]
192 if (videoId) return this.loadVideo(videoId)
193
194 const playlistId = routeParams[ 'playlistId' ]
195 if (playlistId) return this.loadPlaylist(playlistId)
196 })
197 }
198
199 private loadRouteQuery () {
200 this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
201 // Handle the ?playlistPosition
202 const positionParam = queryParams[ 'playlistPosition' ] ?? 1
203
204 this.playlistPosition = positionParam === 'last'
205 ? -1 // Handle the "last" index
206 : parseInt(positionParam + '', 10)
207
208 if (isNaN(this.playlistPosition)) {
209 console.error(`playlistPosition query param '${positionParam}' was parsed as NaN, defaulting to 1.`)
210 this.playlistPosition = 1
211 }
212
213 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
214
215 const start = queryParams[ 'start' ]
216 if (this.player && start) this.player.currentTime(parseInt(start, 10))
217 })
218 }
219
266 private loadVideo (videoId: string) { 220 private loadVideo (videoId: string) {
267 // Video did not change 221 if (this.isSameElement(this.video, videoId)) return
268 if (
269 this.video &&
270 (this.video.uuid === videoId || this.video.shortUUID === videoId)
271 ) return
272 222
273 if (this.player) this.player.pause() 223 if (this.player) this.player.pause()
274 224
@@ -280,90 +230,77 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
280 'filter:api.video-watch.video.get.result' 230 'filter:api.video-watch.video.get.result'
281 ) 231 )
282 232
283 // Video did change 233 forkJoin([ videoObs, this.videoCaptionService.listCaptions(videoId)])
284 forkJoin([ 234 .subscribe(
285 videoObs, 235 ([ video, captionsResult ]) => {
286 this.videoCaptionService.listCaptions(videoId) 236 const queryParams = this.route.snapshot.queryParams
287 ])
288 .pipe(
289 // If 400, 403 or 404, the video is private or blocked so redirect to 404
290 catchError(err => {
291 const errorBody = err.body as PeerTubeProblemDocument
292
293 if (errorBody.code === ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS && errorBody.originUrl) {
294 const search = window.location.search
295 let originUrl = errorBody.originUrl
296 if (search) originUrl += search
297
298 this.confirmService.confirm(
299 $localize`This video is not available on this instance. Do you want to be redirected on the origin instance: <a href="${originUrl}">${originUrl}</a>?`,
300 $localize`Redirection`
301 ).then(res => {
302 if (res === false) {
303 return this.restExtractor.redirectTo404IfNotFound(err, 'video', [
304 HttpStatusCode.BAD_REQUEST_400,
305 HttpStatusCode.FORBIDDEN_403,
306 HttpStatusCode.NOT_FOUND_404
307 ])
308 }
309
310 return window.location.href = originUrl
311 })
312 }
313 237
314 return this.restExtractor.redirectTo404IfNotFound(err, 'video', [ 238 const urlOptions = {
315 HttpStatusCode.BAD_REQUEST_400, 239 resume: queryParams.resume,
316 HttpStatusCode.FORBIDDEN_403,
317 HttpStatusCode.NOT_FOUND_404
318 ])
319 })
320 )
321 .subscribe(([ video, captionsResult ]) => {
322 const queryParams = this.route.snapshot.queryParams
323 240
324 const urlOptions = { 241 startTime: queryParams.start,
325 resume: queryParams.resume, 242 stopTime: queryParams.stop,
326 243
327 startTime: queryParams.start, 244 muted: queryParams.muted,
328 stopTime: queryParams.stop, 245 loop: queryParams.loop,
246 subtitle: queryParams.subtitle,
329 247
330 muted: queryParams.muted, 248 playerMode: queryParams.mode,
331 loop: queryParams.loop, 249 peertubeLink: false
332 subtitle: queryParams.subtitle, 250 }
333 251
334 playerMode: queryParams.mode, 252 this.onVideoFetched(video, captionsResult.data, urlOptions)
335 peertubeLink: false 253 .catch(err => this.handleGlobalError(err))
336 } 254 },
337 255
338 this.onVideoFetched(video, captionsResult.data, urlOptions) 256 err => this.handleRequestError(err)
339 .catch(err => this.handleError(err)) 257 )
340 })
341 } 258 }
342 259
343 private loadPlaylist (playlistId: string) { 260 private loadPlaylist (playlistId: string) {
344 // Playlist did not change 261 if (this.isSameElement(this.playlist, playlistId)) return
345 if (
346 this.playlist &&
347 (this.playlist.uuid === playlistId || this.playlist.shortUUID === playlistId)
348 ) return
349 262
350 this.playlistService.getVideoPlaylist(playlistId) 263 this.playlistService.getVideoPlaylist(playlistId)
351 .pipe( 264 .subscribe(
352 // If 400 or 403, the video is private or blocked so redirect to 404 265 playlist => {
353 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'video', [ 266 this.playlist = playlist
354 HttpStatusCode.BAD_REQUEST_400, 267
355 HttpStatusCode.FORBIDDEN_403, 268 this.videoWatchPlaylist.loadPlaylistElements(playlist, !this.playlistPosition, this.playlistPosition)
356 HttpStatusCode.NOT_FOUND_404 269 },
357 ])) 270
271 err => this.handleRequestError(err)
358 ) 272 )
359 .subscribe(playlist => { 273 }
360 this.playlist = playlist
361 274
362 this.videoWatchPlaylist.loadPlaylistElements(playlist, !this.playlistPosition, this.playlistPosition) 275 private isSameElement (element: VideoDetails | VideoPlaylist, newId: string) {
363 }) 276 if (!element) return false
277
278 return (element.id + '') === newId || element.uuid === newId || element.shortUUID === newId
279 }
280
281 private async handleRequestError (err: any) {
282 const errorBody = err.body as PeerTubeProblemDocument
283
284 if (errorBody.code === ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS && errorBody.originUrl) {
285 const originUrl = errorBody.originUrl + (window.location.search ?? '')
286
287 const res = await this.confirmService.confirm(
288 $localize`This video is not available on this instance. Do you want to be redirected on the origin instance: <a href="${originUrl}">${originUrl}</a>?`,
289 $localize`Redirection`
290 )
291
292 if (res === true) return window.location.href = originUrl
293 }
294
295 // If 400, 403 or 404, the video is private or blocked so redirect to 404
296 return this.restExtractor.redirectTo404IfNotFound(err, 'video', [
297 HttpStatusCode.BAD_REQUEST_400,
298 HttpStatusCode.FORBIDDEN_403,
299 HttpStatusCode.NOT_FOUND_404
300 ])
364 } 301 }
365 302
366 private handleError (err: any) { 303 private handleGlobalError (err: any) {
367 const errorMessage: string = typeof err === 'string' ? err : err.message 304 const errorMessage: string = typeof err === 'string' ? err : err.message
368 if (!errorMessage) return 305 if (!errorMessage) return
369 306
@@ -371,7 +308,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
371 if (errorMessage.indexOf('from xs param') !== -1) { 308 if (errorMessage.indexOf('from xs param') !== -1) {
372 this.flushPlayer() 309 this.flushPlayer()
373 this.remoteServerDown = true 310 this.remoteServerDown = true
374 this.changeDetector.detectChanges()
375 311
376 return 312 return
377 } 313 }
@@ -449,39 +385,44 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
449 this.zone.runOutsideAngular(async () => { 385 this.zone.runOutsideAngular(async () => {
450 this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) 386 this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player)
451 387
452 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) 388 this.player.on('customError', ({ err }: { err: any }) => {
389 this.zone.run(() => this.handleGlobalError(err))
390 })
453 391
454 this.player.on('timeupdate', () => { 392 this.player.on('timeupdate', () => {
393 // Don't need to trigger angular change for this variable, that is sent to children components on click
455 this.currentTime = Math.floor(this.player.currentTime()) 394 this.currentTime = Math.floor(this.player.currentTime())
456 }) 395 })
457 396
458 /** 397 /**
459 * replaces this.player.one('ended') 398 * condition: true to make the upnext functionality trigger, false to disable the upnext functionality
460 * 'condition()': true to make the upnext functionality trigger, 399 * go to the next video in 'condition()' if you don't want of the timer.
461 * false to disable the upnext functionality 400 * next: function triggered at the end of the timer.
462 * go to the next video in 'condition()' if you don't want of the timer. 401 * suspended: function used at each click of the timer checking if we need to reset progress
463 * 'next': function triggered at the end of the timer. 402 * and wait until suspended becomes truthy again.
464 * 'suspended': function used at each clic of the timer checking if we need
465 * to reset progress and wait until 'suspended' becomes truthy again.
466 */ 403 */
467 this.player.upnext({ 404 this.player.upnext({
468 timeout: 10000, // 10s 405 timeout: 5000, // 5s
406
469 headText: $localize`Up Next`, 407 headText: $localize`Up Next`,
470 cancelText: $localize`Cancel`, 408 cancelText: $localize`Cancel`,
471 suspendedText: $localize`Autoplay is suspended`, 409 suspendedText: $localize`Autoplay is suspended`,
410
472 getTitle: () => this.nextVideoTitle, 411 getTitle: () => this.nextVideoTitle,
473 next: () => this.zone.run(() => this.autoplayNext()), 412
413 next: () => this.zone.run(() => this.playNextVideoInAngularZone()),
474 condition: () => { 414 condition: () => {
475 if (this.playlist) { 415 if (!this.playlist) return this.isAutoPlayNext()
476 if (this.isPlaylistAutoPlayEnabled()) { 416
477 // upnext will not trigger, and instead the next video will play immediately 417 // Don't wait timeout to play the next playlist video
478 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) 418 if (this.isPlaylistAutoPlayNext()) {
479 } 419 this.playNextVideoInAngularZone()
480 } else if (this.isAutoPlayEnabled()) { 420 return undefined
481 return true // upnext will trigger
482 } 421 }
483 return false // upnext will not trigger, and instead leave the video stopping 422
423 return false
484 }, 424 },
425
485 suspended: () => { 426 suspended: () => {
486 return ( 427 return (
487 !isXPercentInViewport(this.player.el(), 80) || 428 !isXPercentInViewport(this.player.el(), 80) ||
@@ -491,8 +432,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
491 }) 432 })
492 433
493 this.player.one('stopped', () => { 434 this.player.one('stopped', () => {
494 if (this.playlist) { 435 if (this.playlist && this.isPlaylistAutoPlayNext()) {
495 if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) 436 this.playNextVideoInAngularZone()
496 } 437 }
497 }) 438 })
498 439
@@ -510,33 +451,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
510 }) 451 })
511 } 452 }
512 453
513 private autoplayNext () { 454 private playNextVideoInAngularZone () {
514 if (this.playlist) { 455 if (this.playlist) {
515 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) 456 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
516 } else if (this.nextVideoUuid) { 457 return
517 this.router.navigate([ '/w', this.nextVideoUuid ])
518 } 458 }
519 }
520
521 private setOpenGraphTags () {
522 this.metaService.setTitle(this.video.name)
523
524 this.metaService.setTag('og:type', 'video')
525
526 this.metaService.setTag('og:title', this.video.name)
527 this.metaService.setTag('name', this.video.name)
528 459
529 this.metaService.setTag('og:description', this.video.description) 460 if (this.nextVideoUUID) {
530 this.metaService.setTag('description', this.video.description) 461 this.router.navigate([ '/w', this.nextVideoUUID ])
531 462 return
532 this.metaService.setTag('og:image', this.video.previewPath) 463 }
533
534 this.metaService.setTag('og:duration', this.video.duration.toString())
535
536 this.metaService.setTag('og:site_name', 'PeerTube')
537
538 this.metaService.setTag('og:url', window.location.href)
539 this.metaService.setTag('url', window.location.href)
540 } 464 }
541 465
542 private isAutoplay () { 466 private isAutoplay () {
@@ -550,6 +474,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
550 return this.user.autoPlayVideo !== false 474 return this.user.autoPlayVideo !== false
551 } 475 }
552 476
477 private isAutoPlayNext () {
478 return (
479 (this.user && this.user.autoPlayNextVideo) ||
480 this.anonymousUser.autoPlayNextVideo
481 )
482 }
483
484 private isPlaylistAutoPlayNext () {
485 return (
486 (this.user && this.user.autoPlayNextVideoPlaylist) ||
487 this.anonymousUser.autoPlayNextVideoPlaylist
488 )
489 }
490
553 private flushPlayer () { 491 private flushPlayer () {
554 // Remove player if it exists 492 // Remove player if it exists
555 if (!this.player) return 493 if (!this.player) return
@@ -569,6 +507,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
569 user?: AuthUser 507 user?: AuthUser
570 }) { 508 }) {
571 const { video, videoCaptions, urlOptions, user } = params 509 const { video, videoCaptions, urlOptions, user } = params
510
572 const getStartTime = () => { 511 const getStartTime = () => {
573 const byUrl = urlOptions.startTime !== undefined 512 const byUrl = urlOptions.startTime !== undefined
574 const byHistory = video.userHistory && (!this.playlist || urlOptions.resume !== undefined) 513 const byHistory = video.userHistory && (!this.playlist || urlOptions.resume !== undefined)
@@ -595,7 +534,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
595 const options: PeertubePlayerManagerOptions = { 534 const options: PeertubePlayerManagerOptions = {
596 common: { 535 common: {
597 autoplay: this.isAutoplay(), 536 autoplay: this.isAutoplay(),
598 nextVideo: () => this.zone.run(() => this.autoplayNext()), 537 nextVideo: () => this.playNextVideoInAngularZone(),
599 538
600 playerElement: this.playerElement, 539 playerElement: this.playerElement,
601 onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, 540 onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
@@ -663,7 +602,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
663 else mode = 'webtorrent' 602 else mode = 'webtorrent'
664 } 603 }
665 604
666 // p2p-media-loader needs TextEncoder, try to fallback on WebTorrent 605 // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
667 if (typeof TextEncoder === 'undefined') { 606 if (typeof TextEncoder === 'undefined') {
668 mode = 'webtorrent' 607 mode = 'webtorrent'
669 } 608 }
@@ -717,7 +656,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
717 656
718 const videoUUID = this.video.uuid 657 const videoUUID = this.video.uuid
719 658
720 // Reset to refetch the video 659 // Reset to force refresh the video
721 this.video = undefined 660 this.video = undefined
722 this.loadVideo(videoUUID) 661 this.loadVideo(videoUUID)
723 } 662 }
@@ -765,4 +704,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
765 704
766 this.hotkeysService.add(this.hotkeys) 705 this.hotkeysService.add(this.hotkeys)
767 } 706 }
707
708 private setOpenGraphTags () {
709 this.metaService.setTitle(this.video.name)
710
711 this.metaService.setTag('og:type', 'video')
712
713 this.metaService.setTag('og:title', this.video.name)
714 this.metaService.setTag('name', this.video.name)
715
716 this.metaService.setTag('og:description', this.video.description)
717 this.metaService.setTag('description', this.video.description)
718
719 this.metaService.setTag('og:image', this.video.previewPath)
720
721 this.metaService.setTag('og:duration', this.video.duration.toString())
722
723 this.metaService.setTag('og:site_name', 'PeerTube')
724
725 this.metaService.setTag('og:url', window.location.href)
726 this.metaService.setTag('url', window.location.href)
727 }
768} 728}
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts
index 4669a700c..8f5ffdb1d 100644
--- a/client/src/app/+videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.module.ts
@@ -21,7 +21,8 @@ import {
21 VideoAvatarChannelComponent, 21 VideoAvatarChannelComponent,
22 VideoDescriptionComponent, 22 VideoDescriptionComponent,
23 VideoRateComponent, 23 VideoRateComponent,
24 VideoWatchPlaylistComponent 24 VideoWatchPlaylistComponent,
25 VideoAttributesComponent
25} from './shared' 26} from './shared'
26import { VideoCommentAddComponent } from './shared/comment/video-comment-add.component' 27import { VideoCommentAddComponent } from './shared/comment/video-comment-add.component'
27import { VideoCommentComponent } from './shared/comment/video-comment.component' 28import { VideoCommentComponent } from './shared/comment/video-comment.component'
@@ -57,6 +58,7 @@ import { VideoWatchComponent } from './video-watch.component'
57 PrivacyConcernsComponent, 58 PrivacyConcernsComponent,
58 ActionButtonsComponent, 59 ActionButtonsComponent,
59 VideoAlertComponent, 60 VideoAlertComponent,
61 VideoAttributesComponent,
60 62
61 VideoCommentsComponent, 63 VideoCommentsComponent,
62 VideoCommentAddComponent, 64 VideoCommentAddComponent,