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 3a7629cc6..50854c592 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -36,7 +36,6 @@ import { getStoredTheater } from '../../../assets/player/peertube-player-local-s import { PluginService } from '@app/core/plugins/plugin.service' import { HooksService } from '@app/core/plugins/hooks.service' import { PlatformLocation } from '@angular/common' -import { randomInt } from '@shared/core-utils/miscs/miscs' import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component' import { scrollToTop } from '@app/shared/misc/utils' @@ -79,6 +78,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { tooltipSaveToPlaylist = '' private nextVideoUuid = '' + private nextVideoTitle = '' private currentTime: number private paramsSub: Subscription private queryParamsSub: Subscription @@ -247,8 +247,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { onRecommendations (videos: Video[]) { if (videos.length > 0) { - // Pick a random video until the recommendations are improved - this.nextVideoUuid = videos[randomInt(0,videos.length - 1)].uuid + // The recommended videos's first element should be the next video + const video = videos[0] + this.nextVideoUuid = video.uuid + this.nextVideoTitle = video.name } } @@ -468,11 +470,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.currentTime = Math.floor(this.player.currentTime()) }) - this.player.one('ended', () => { - if (this.playlist) { - if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) - } else if (this.isAutoPlayEnabled()) { - this.zone.run(() => this.autoplayNext()) + /** + * replaces this.player.one('ended') + * define 'condition(next)' to return true to wait, false to stop + */ + this.player.upnext({ + timeout: 1000000, + headText: this.i18n('Up Next'), + cancelText: this.i18n('Cancel'), + getTitle: () => this.nextVideoTitle, + next: () => this.zone.run(() => this.autoplayNext()), + condition: () => { + if (this.playlist) { + if (this.isPlaylistAutoPlayEnabled()) { + // upnext will not trigger, and instead the next video will play immediately + this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) + } + } else if (this.isAutoPlayEnabled()) { + return true // upnext will trigger + } + return false // upnext will not trigger, and instead leave the video stopping } }) diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index d10fb7a4a..2f4e0ac1a 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -5,6 +5,7 @@ import 'videojs-hotkeys' import 'videojs-dock' import 'videojs-contextmenu-ui' import 'videojs-contrib-quality-levels' +import './upnext/upnext-plugin' import './peertube-plugin' import './videojs-components/peertube-link-button' import './videojs-components/resolution-menu-button' diff --git a/client/src/assets/player/upnext/upnext-plugin.ts b/client/src/assets/player/upnext/upnext-plugin.ts new file mode 100644 index 000000000..1f0705481 --- /dev/null +++ b/client/src/assets/player/upnext/upnext-plugin.ts @@ -0,0 +1,169 @@ +// @ts-ignore +import * as videojs from 'video.js' +import { VideoJSComponentInterface } from '../peertube-videojs-typings' + +function getMainTemplate (options: any) { + return ` +
+ + + ` +} + +// @ts-ignore-start +const Component = videojs.getComponent('Component') +class EndCard extends Component { + options_: any + getTitle: Function + next: Function + condition: Function + dashOffsetTotal = 586 + dashOffsetStart = 293 + interval = 50 + upNextEvents = new videojs.EventTarget() + chunkSize: number + + container: HTMLElement + title: HTMLElement + autoplayRing: HTMLElement + cancelButton: HTMLElement + nextButton: HTMLElement + + constructor (player: videojs.Player, options: any) { + super(player, options) + this.options_ = options + + this.getTitle = this.options_.getTitle + this.next = this.options_.next + this.condition = this.options_.condition + + this.chunkSize = (this.dashOffsetTotal - this.dashOffsetStart) / (this.options_.timeout / this.interval) + + player.on('ended', (_: any) => { + if (!this.condition()) return + + player.addClass('vjs-upnext--showing') + this.showCard((canceled: boolean) => { + player.removeClass('vjs-upnext--showing') + this.container.style.display = 'none' + if (!canceled) { + this.next() + } + }) + }) + + player.on('playing', () => { + this.upNextEvents.trigger('playing') + }) + } + + createEl () { + const container = super.createEl('div', { + className: 'vjs-upnext-content', + innerHTML: getMainTemplate(this.options_) + }) + + this.container = container + container.style.display = 'none' + + this.autoplayRing = container.getElementsByClassName('vjs-upnext-svg-autoplay-ring')[0] + this.title = container.getElementsByClassName('vjs-upnext-title')[0] + this.cancelButton = container.getElementsByClassName('vjs-upnext-cancel-button')[0] + this.nextButton = container.getElementsByClassName('vjs-upnext-autoplay-icon')[0] + + this.cancelButton.onclick = () => { + this.upNextEvents.trigger('cancel') + } + + this.nextButton.onclick = () => { + this.upNextEvents.trigger('next') + } + + return container + } + + showCard (cb: Function) { + let timeout: any + let start: number + let now: number + let newOffset: number + + this.autoplayRing.setAttribute('stroke-dasharray', this.dashOffsetStart) + this.autoplayRing.setAttribute('stroke-dashoffset', -this.dashOffsetStart) + + this.title.innerHTML = this.getTitle() + + this.upNextEvents.one('cancel', () => { + clearTimeout(timeout) + cb(true) + }) + + this.upNextEvents.one('playing', () => { + clearTimeout(timeout) + cb(true) + }) + + this.upNextEvents.one('next', () => { + clearTimeout(timeout) + cb(false) + }) + + const update = () => { + now = this.options_.timeout - (new Date().getTime() - start) + + if (now <= 0) { + clearTimeout(timeout) + cb(false) + } else { + newOffset = Math.max(-this.dashOffsetTotal, this.autoplayRing.getAttribute('stroke-dashoffset') - this.chunkSize) + this.autoplayRing.setAttribute('stroke-dashoffset', newOffset) + timeout = setTimeout(update.bind(this), this.interval) + } + + } + + this.container.style.display = 'block' + start = new Date().getTime() + timeout = setTimeout(update.bind(this), this.interval) + } +} +// @ts-ignore-end + +videojs.registerComponent('EndCard', EndCard) + +const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') +class UpNextPlugin extends Plugin { + constructor (player: videojs.Player, options: any = {}) { + const settings = { + next: options.next, + getTitle: options.getTitle, + timeout: options.timeout || 5000, + cancelText: options.cancelText || 'Cancel', + headText: options.headText || 'Up Next', + condition: options.condition + } + + super(player, settings) + + this.player.ready(() => { + player.addClass('vjs-upnext') + }) + + player.addChild('EndCard', settings) + } +} + +videojs.registerPlugin('upnext', UpNextPlugin) +export { UpNextPlugin } diff --git a/client/src/sass/player/index.scss b/client/src/sass/player/index.scss index e4a315d1f..886a76536 100644 --- a/client/src/sass/player/index.scss +++ b/client/src/sass/player/index.scss @@ -2,4 +2,5 @@ @import './mobile'; @import './context-menu'; @import './settings-menu'; -@import './spinner'; \ No newline at end of file +@import './spinner'; +@import './upnext'; \ No newline at end of file diff --git a/client/src/sass/player/upnext.scss b/client/src/sass/player/upnext.scss new file mode 100644 index 000000000..ecce22aa8 --- /dev/null +++ b/client/src/sass/player/upnext.scss @@ -0,0 +1,108 @@ +$browser-context: 16; + +@function em($pixels, $context: $browser-context) { + @return #{$pixels/$context}em; +} + +@mixin transition($string: $transition--default) { + transition: $string; +} + +.video-js { + + .vjs-upnext-content { + font-size: 1.8em; + pointer-events: auto; + position: absolute; + top: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + width: 100%; + + @include transition(opacity 0.1s); + } + + .vjs-upnext-top { + width: 100%; + position: absolute; + margin-left: auto; + margin-right: auto; + bottom: 50%; + margin-bottom: 60px; + } + + .vjs-upnext-bottom { + width: 100%; + position: absolute; + margin-left: auto; + margin-right: auto; + top: 50%; + margin-top: 52px; + } + + .vjs-upnext-cancel { + display: block; + float: none; + text-align: center; + } + + .vjs-upnext-headtext { + display: block; + font-size: 14px; + text-align: center; + padding-bottom: 7px; + } + + .vjs-upnext-title { + display: block; + padding: 10px 10px 2px; + text-align: center; + font-size: 22px; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + word-wrap: normal; + text-overflow: ellipsis; + } + + .vjs-upnext-cancel-button { + cursor: pointer; + display: inline-block; + float: none; + padding: 10px !important; + font-size: 16px !important; + border: none; + } + + .vjs-upnext-cancel-button, + .vjs-upnext-cancel-button:focus { + outline: 0; + } + + .vjs-upnext-cancel-button:hover { + background-color: rgba(255,255,255,0.25); + border-radius: 2px; + } + + &.vjs-no-flex .vjs-upnext-content { + padding-bottom: 1em; + } + + .vjs-upnext-autoplay-icon { + position: absolute; + top: 50%; + left: 50%; + width: 98px; + height: 98px; + margin: -49px 0 0 -49px; + transition: stroke-dasharray 0.1s cubic-bezier(0.4,0,1,1); + cursor: pointer; + } + +} + +.video-js.vjs-upnext--showing { + .vjs-control-bar { + z-index: 1; + } +}