@@ -20,6 +48,10 @@
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
This video is blacklisted.
{{ 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 @@
@import '_variables';
@import '_mixins';
@import '_bootstrap-variables';
+@import '_miniature';
$other-videos-width: 260px;
@@ -12,7 +13,7 @@ $other-videos-width: 260px;
font-weight: $font-semibold;
}
-#video-element-wrapper {
+#video-wrapper {
background-color: #000;
display: flex;
justify-content: center;
@@ -39,6 +40,57 @@ $other-videos-width: 260px;
}
}
+ .playlist {
+ width: 400px;
+ height: 66vh;
+ background-color: #e4e4e4;
+ overflow-y: auto;
+
+ .playlist-info {
+ padding: 5px 30px;
+
+ .playlist-display-name {
+ font-size: 18px;
+ font-weight: $font-semibold;
+ margin-bottom: 5px;
+ }
+
+ .playlist-by-index {
+ color: $grey-foreground-color;
+ display: flex;
+
+ .playlist-by {
+ margin-right: 5px;
+ }
+
+ .playlist-index span:first-child::after {
+ content: '/';
+ margin: 0 3px;
+ }
+ }
+ }
+
+ my-video-playlist-element-miniature {
+ /deep/ {
+ .video {
+ .position {
+ margin-right: 0;
+ }
+
+ .video-info {
+ .video-info-name {
+ font-size: 15px;
+ }
+ }
+ }
+
+ my-video-thumbnail {
+ @include thumbnail-size-component(90px, 50px);
+ }
+ }
+ }
+ }
+
/deep/ .video-js {
width: calc(66vh * 1.77);
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'
import { Notifier, ServerService } from '@app/core'
import { forkJoin, Subscription } from 'rxjs'
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
+import { UserVideoRateType, VideoCaption, VideoPlaylistPrivacy, VideoPrivacy, VideoState } from '../../../../../shared'
import { AuthService, ConfirmService } from '../../core'
import { RestExtractor, VideoBlacklistService } from '../../shared'
import { VideoDetails } from '../../shared/video/video-details.model'
@@ -28,6 +28,10 @@ import {
PeertubePlayerManagerOptions,
PlayerMode
} from '../../../assets/player/peertube-player-manager'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
+import { Video } from '@app/shared/video/video.model'
@Component({
selector: 'my-video-watch',
@@ -50,6 +54,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null
descriptionLoading = false
+ playlist: VideoPlaylist = null
+ playlistVideos: Video[] = []
+ playlistPagination: ComponentPagination = {
+ currentPage: 1,
+ itemsPerPage: 10,
+ totalItems: null
+ }
+ noPlaylistVideos = false
+ currentPlaylistPosition = 1
+
completeDescriptionShown = false
completeVideoDescription: string
shortVideoDescription: string
@@ -61,6 +75,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private currentTime: number
private paramsSub: Subscription
+ private queryParamsSub: Subscription
constructor (
private elementRef: ElementRef,
@@ -68,6 +83,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private videoService: VideoService,
+ private playlistService: VideoPlaylistService,
private videoBlacklistService: VideoBlacklistService,
private confirmService: ConfirmService,
private metaService: MetaService,
@@ -97,31 +113,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}
this.paramsSub = this.route.params.subscribe(routeParams => {
- const uuid = routeParams[ 'uuid' ]
+ const videoId = routeParams[ 'videoId' ]
+ if (videoId) this.loadVideo(videoId)
- // Video did not change
- if (this.video && this.video.uuid === uuid) return
+ const playlistId = routeParams[ 'playlistId' ]
+ if (playlistId) this.loadPlaylist(playlistId)
+ })
- if (this.player) this.player.pause()
-
- // Video did change
- forkJoin(
- this.videoService.getVideo(uuid),
- this.videoCaptionService.listCaptions(uuid)
- )
- .pipe(
- // If 401, the video is private or blacklisted so redirect to 404
- catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
- )
- .subscribe(([ video, captionsResult ]) => {
- const startTime = this.route.snapshot.queryParams.start
- const stopTime = this.route.snapshot.queryParams.stop
- const subtitle = this.route.snapshot.queryParams.subtitle
- const playerMode = this.route.snapshot.queryParams.mode
-
- this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
- .catch(err => this.handleError(err))
- })
+ this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
+ const videoId = queryParams[ 'videoId' ]
+ if (videoId) this.loadVideo(videoId)
})
this.hotkeys = [
@@ -147,7 +148,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.flushPlayer()
// Unsubscribe subscriptions
- this.paramsSub.unsubscribe()
+ if (this.paramsSub) this.paramsSub.unsubscribe()
+ if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
// Unbind hotkeys
if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
@@ -219,8 +221,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}
showShareModal () {
- const currentTime = this.player ? this.player.currentTime() : undefined
-
this.videoShareModal.show(this.currentTime)
}
@@ -322,6 +322,107 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.video && this.video.scheduledUpdate !== undefined
}
+ isVideoBlur (video: Video) {
+ return video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
+ }
+
+ isPlaylistOwned () {
+ return this.playlist.isLocal === true && this.playlist.ownerAccount.name === this.user.username
+ }
+
+ isUnlistedPlaylist () {
+ return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
+ }
+
+ isPrivatePlaylist () {
+ return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
+ }
+
+ isPublicPlaylist () {
+ return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
+ }
+
+ onPlaylistVideosNearOfBottom () {
+ // Last page
+ if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
+
+ this.playlistPagination.currentPage += 1
+ this.loadPlaylistElements(false)
+ }
+
+ onElementRemoved (video: Video) {
+ this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id)
+
+ this.playlistPagination.totalItems--
+ }
+
+ private loadVideo (videoId: string) {
+ // Video did not change
+ if (this.video && this.video.uuid === videoId) return
+
+ if (this.player) this.player.pause()
+
+ // Video did change
+ forkJoin(
+ this.videoService.getVideo(videoId),
+ this.videoCaptionService.listCaptions(videoId)
+ )
+ .pipe(
+ // If 401, the video is private or blacklisted so redirect to 404
+ catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
+ )
+ .subscribe(([ video, captionsResult ]) => {
+ const queryParams = this.route.snapshot.queryParams
+ const startTime = queryParams.start
+ const stopTime = queryParams.stop
+ const subtitle = queryParams.subtitle
+ const playerMode = queryParams.mode
+
+ this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
+ .catch(err => this.handleError(err))
+ })
+ }
+
+ private loadPlaylist (playlistId: string) {
+ // Playlist did not change
+ if (this.playlist && this.playlist.uuid === playlistId) return
+
+ this.playlistService.getVideoPlaylist(playlistId)
+ .pipe(
+ // If 401, the video is private or blacklisted so redirect to 404
+ catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
+ )
+ .subscribe(playlist => {
+ this.playlist = playlist
+
+ const videoId = this.route.snapshot.queryParams['videoId']
+ this.loadPlaylistElements(!videoId)
+ })
+ }
+
+ private loadPlaylistElements (redirectToFirst = false) {
+ this.videoService.getPlaylistVideos(this.playlist.id, this.playlistPagination)
+ .subscribe(({ totalVideos, videos }) => {
+ this.playlistVideos = this.playlistVideos.concat(videos)
+ this.playlistPagination.totalItems = totalVideos
+
+ if (totalVideos === 0) {
+ this.noPlaylistVideos = true
+ return
+ }
+
+ this.updatePlaylistIndex()
+
+ if (redirectToFirst) {
+ const extras = {
+ queryParams: { videoId: this.playlistVideos[ 0 ].uuid },
+ replaceUrl: true
+ }
+ this.router.navigate([], extras)
+ }
+ })
+ }
+
private updateVideoDescription (description: string) {
this.video.description = description
this.setVideoDescriptionHTML()
@@ -383,11 +484,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.remoteServerDown = false
this.currentTime = undefined
+ this.updatePlaylistIndex()
+
let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
// If we are at the end of the video, reset the timer
if (this.video.duration - startTime <= 1) startTime = 0
- if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
+ if (this.isVideoBlur(this.video)) {
const res = await this.confirmService.confirm(
this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
this.i18n('Mature or explicit content')
@@ -399,7 +502,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.flushPlayer()
// Build video element, because videojs remove it on dispose
- const playerElementWrapper = this.elementRef.nativeElement.querySelector('#video-element-wrapper')
+ const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
this.playerElement = document.createElement('video')
this.playerElement.className = 'video-js vjs-peertube-skin'
this.playerElement.setAttribute('playsinline', 'true')
@@ -474,6 +577,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.player.on('timeupdate', () => {
this.currentTime = Math.floor(this.player.currentTime())
})
+
+ this.player.one('ended', () => {
+ if (this.playlist) {
+ this.zone.run(() => this.navigateToNextPlaylistVideo())
+ }
+ })
+
+ this.player.one('stopped', () => {
+ if (this.playlist) {
+ this.zone.run(() => this.navigateToNextPlaylistVideo())
+ }
+ })
})
this.setVideoDescriptionHTML()
@@ -528,6 +643,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.setVideoLikesBarTooltipText()
}
+ private updatePlaylistIndex () {
+ if (this.playlistVideos.length === 0 || !this.video) return
+
+ for (const video of this.playlistVideos) {
+ if (video.id === this.video.id) {
+ this.currentPlaylistPosition = video.playlistElement.position
+ return
+ }
+ }
+
+ // Load more videos to find our video
+ this.onPlaylistVideosNearOfBottom()
+ }
+
private setOpenGraphTags () {
this.metaService.setTitle(this.video.name)
@@ -567,4 +696,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.player = undefined
}
}
+
+ private navigateToNextPlaylistVideo () {
+ if (this.currentPlaylistPosition < this.playlistPagination.totalItems) {
+ const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1)
+
+ const start = next.playlistElement.startTimestamp
+ const stop = next.playlistElement.stopTimestamp
+ this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } })
+ }
+ }
}
diff --git a/client/src/assets/images/global/play.html b/client/src/assets/images/global/play.html
new file mode 100644
index 000000000..d00122de4
--- /dev/null
+++ b/client/src/assets/images/global/play.html
@@ -0,0 +1,9 @@
+
diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
index 4dbfda300..bbd3e008d 100644
--- a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
+++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts
@@ -4,6 +4,7 @@ import * as videojs from 'video.js'
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings'
import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
import { Events } from 'p2p-media-loader-core'
+import { timeToInt } from '../utils'
// videojs-hlsjs-plugin needs videojs in window
window['videojs'] = videojs
@@ -32,6 +33,7 @@ class P2pMediaLoaderPlugin extends Plugin {
totalDownload: 0,
totalUpload: 0
}
+ private startTime: number
private networkInfoInterval: any
@@ -54,12 +56,14 @@ class P2pMediaLoaderPlugin extends Plugin {
initVideoJsContribHlsJsPlayer(player)
+ this.startTime = timeToInt(options.startTime)
+
player.src({
type: options.type,
src: options.src
})
- player.on('play', () => {
+ player.one('play', () => {
player.addClass('vjs-has-big-play-button-clicked')
})
@@ -92,6 +96,12 @@ class P2pMediaLoaderPlugin extends Plugin {
this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length
this.runStats()
+
+ this.hlsjs.on('hlsLevelLoaded', () => {
+ if (this.startTime) this.player.currentTime(this.startTime)
+
+ this.hlsjs.off('hlsLevelLoaded', this)
+ })
}
private runStats () {
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts
index 3991e4627..dd9408c8e 100644
--- a/client/src/assets/player/peertube-plugin.ts
+++ b/client/src/assets/player/peertube-plugin.ts
@@ -83,9 +83,15 @@ class PeerTubePlugin extends Plugin {
if (options.stopTime) {
const stopTime = timeToInt(options.stopTime)
+ const self = this
- this.player.on('timeupdate', () => {
- if (this.player.currentTime() > stopTime) this.player.pause()
+ this.player.on('timeupdate', function onTimeUpdate () {
+ if (self.player.currentTime() > stopTime) {
+ self.player.pause()
+ self.player.trigger('stopped')
+
+ self.player.off('timeupdate', onTimeUpdate)
+ }
})
}
diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss
index 25a024aac..95b759225 100644
--- a/client/src/sass/include/_miniature.scss
+++ b/client/src/sass/include/_miniature.scss
@@ -28,15 +28,15 @@ $play-overlay-transition: 0.2s ease;
$play-overlay-height: 26px;
$play-overlay-width: 18px;
-@mixin miniature-thumbnail($width: $video-thumbnail-width, $height: $video-thumbnail-height) {
+@mixin miniature-thumbnail {
@include disable-outline;
display: inline-block;
position: relative;
border-radius: 3px;
overflow: hidden;
- width: $width;
- height: $height;
+ width: $video-thumbnail-width;
+ height: $video-thumbnail-height;
background-color: #ececec;
transition: filter $play-overlay-transition;
@@ -97,6 +97,13 @@ $play-overlay-width: 18px;
}
}
+@mixin thumbnail-size-component ($width, $height) {
+ /deep/ .video-thumbnail {
+ width: $width;
+ height: $height;
+ }
+}
+
@mixin static-thumbnail-overlay {
display: inline-block;
background-color: rgba(0, 0, 0, 0.7);
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index 7faeec6bd..9b18f6354 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -63,11 +63,11 @@
@mixin apply-svg-color ($color) {
/deep/ svg {
- path[fill="#000000"], g[fill="#000000"], rect[fill="#000000"], circle[fill="#000000"] {
+ path[fill="#000000"], g[fill="#000000"], rect[fill="#000000"], circle[fill="#000000"], polygon[fill="#000000"] {
fill: $color;
}
- path[stroke="#000000"], g[stroke="#000000"], rect[stroke="#000000"], circle[stroke="#000000"] {
+ path[stroke="#000000"], g[stroke="#000000"], rect[stroke="#000000"], circle[stroke="#000000"], polygon[stroke="#000000"] {
stroke: $color;
}
}