Handle basic playlist in embed

pull/3047/head
Chocobozzz 2020-08-05 09:44:58 +02:00 committed by Chocobozzz
parent 5abc96fca2
commit 4572c3d0d9
13 changed files with 570 additions and 25 deletions

View File

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
<g id="Artboard-4" transform="translate(-356.000000, -115.000000)" stroke="#fff" stroke-width="2">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
<g transform="translate(-356.000000, -115.000000)" stroke="#fff" stroke-width="2">
<g id="8" transform="translate(356.000000, 115.000000)">
<path d="M21,6 L9,18" id="Path-14"></path>
<path d="M9,13 L4,18" id="Path-14" transform="translate(6.500000, 15.500000) scale(-1, 1) translate(-6.500000, -15.500000) "></path>

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 692 B

View File

@ -18,14 +18,21 @@ import './videojs-components/settings-menu-item'
import './videojs-components/settings-panel'
import './videojs-components/settings-panel-child'
import './videojs-components/theater-button'
import './playlist/playlist-plugin'
import videojs from 'video.js'
import { VideoFile } from '@shared/models'
import { isDefaultLocale } from '@shared/core-utils/i18n'
import { VideoFile } from '@shared/models'
import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
import { getStoredP2PEnabled } from './peertube-player-local-storage'
import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions } from './peertube-videojs-typings'
import {
P2PMediaLoaderPluginOptions,
PlaylistPluginOptions,
UserWatching,
VideoJSCaption,
VideoJSPluginOptions
} from './peertube-videojs-typings'
import { TranslationsManager } from './translations-manager'
import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isIOS, isSafari } from './utils'
@ -71,6 +78,9 @@ export interface CommonOptions extends CustomizationOptions {
autoplay: boolean
nextVideo?: Function
playlist?: PlaylistPluginOptions
videoDuration: number
enableHotkeys: boolean
inactivityTimeout: number
@ -203,6 +213,10 @@ export class PeertubePlayerManager {
}
}
if (commonOptions.playlist) {
plugins.playlist = commonOptions.playlist
}
if (commonOptions.enableHotkeys === true) {
PeertubePlayerManager.addHotkeysOptions(plugins)
}

View File

@ -1,10 +1,11 @@
import { Config, Level } from 'hls.js'
import videojs from 'video.js'
import { VideoFile } from '@shared/models'
import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin'
import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
import { PlayerMode } from './peertube-player-manager'
import { PeerTubePlugin } from './peertube-plugin'
import { PlaylistPlugin } from './playlist/playlist-plugin'
import { EndCardOptions } from './upnext/end-card'
import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin'
@ -45,6 +46,8 @@ declare module 'video.js' {
dock (options: { title: string, description: string }): void
upnext (options: Partial<EndCardOptions>): void
playlist (): PlaylistPlugin
}
}
@ -105,6 +108,16 @@ type PeerTubePluginOptions = {
stopTime: number | string
}
type PlaylistPluginOptions = {
elements: VideoPlaylistElement[]
playlist: VideoPlaylist
getCurrentPosition: () => number
onItemClicked: (element: VideoPlaylistElement) => void
}
type WebtorrentPluginOptions = {
playerElement: HTMLVideoElement
@ -125,6 +138,8 @@ type P2PMediaLoaderPluginOptions = {
}
type VideoJSPluginOptions = {
playlist?: PlaylistPluginOptions
peertube: PeerTubePluginOptions
webtorrent?: WebtorrentPluginOptions
@ -170,10 +185,18 @@ type PlayerNetworkInfo = {
}
}
type PlaylistItemOptions = {
element: VideoPlaylistElement
onClicked: Function
}
export {
PlayerNetworkInfo,
PlaylistItemOptions,
ResolutionUpdateData,
AutoResolutionUpdateData,
PlaylistPluginOptions,
VideoJSCaption,
UserWatching,
PeerTubePluginOptions,

View File

@ -0,0 +1,61 @@
import videojs from 'video.js'
import { PlaylistPluginOptions } from '../peertube-videojs-typings'
import { PlaylistMenu } from './playlist-menu'
const ClickableComponent = videojs.getComponent('ClickableComponent')
class PlaylistButton extends ClickableComponent {
private playlistInfoElement: HTMLElement
private wrapper: HTMLElement
constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) {
super(player, options as any)
}
createEl () {
this.wrapper = super.createEl('div', {
className: 'vjs-playlist-button',
innerHTML: '',
tabIndex: -1
}) as HTMLElement
const icon = super.createEl('div', {
className: 'vjs-playlist-icon',
innerHTML: '',
tabIndex: -1
})
this.playlistInfoElement = super.createEl('div', {
className: 'vjs-playlist-info',
innerHTML: '',
tabIndex: -1
}) as HTMLElement
this.wrapper.appendChild(icon)
this.wrapper.appendChild(this.playlistInfoElement)
this.update()
return this.wrapper
}
update () {
const options = this.options_ as PlaylistPluginOptions
this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength
this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ])
}
handleClick () {
const playlistMenu = this.getPlaylistMenu()
playlistMenu.open()
}
private getPlaylistMenu () {
return (this.options_ as any).playlistMenu as PlaylistMenu
}
}
videojs.registerComponent('PlaylistButton', PlaylistButton)
export { PlaylistButton }

View File

@ -0,0 +1,98 @@
import videojs from 'video.js'
import { VideoPlaylistElement } from '@shared/models'
import { PlaylistItemOptions } from '../peertube-videojs-typings'
const Component = videojs.getComponent('Component')
class PlaylistMenuItem extends Component {
private element: VideoPlaylistElement
constructor (player: videojs.Player, options?: PlaylistItemOptions) {
super(player, options as any)
this.emitTapEvents()
this.element = options.element
this.on([ 'click', 'tap' ], () => this.switchPlaylistItem())
this.on('keydown', event => this.handleKeyDown(event))
}
createEl () {
const options = this.options_ as PlaylistItemOptions
const li = super.createEl('li', {
className: 'vjs-playlist-menu-item',
innerHTML: ''
}) as HTMLElement
const positionBlock = super.createEl('div', {
className: 'item-position-block'
})
const position = super.createEl('div', {
className: 'item-position',
innerHTML: options.element.position
})
const player = super.createEl('div', {
className: 'item-player'
})
positionBlock.appendChild(position)
positionBlock.appendChild(player)
li.appendChild(positionBlock)
const thumbnail = super.createEl('img', {
src: window.location.origin + options.element.video.thumbnailPath
})
const infoBlock = super.createEl('div', {
className: 'info-block'
})
const title = super.createEl('div', {
innerHTML: options.element.video.name,
className: 'title'
})
const channel = super.createEl('div', {
innerHTML: options.element.video.channel.displayName,
className: 'channel'
})
infoBlock.appendChild(title)
infoBlock.appendChild(channel)
li.append(thumbnail)
li.append(infoBlock)
return li
}
setSelected (selected: boolean) {
if (selected) this.addClass('vjs-selected')
else this.removeClass('vjs-selected')
}
getElement () {
return this.element
}
private handleKeyDown (event: KeyboardEvent) {
if (event.code === 'Space' || event.code === 'Enter') {
this.switchPlaylistItem()
}
}
private switchPlaylistItem () {
const options = this.options_ as PlaylistItemOptions
options.onClicked()
}
}
Component.registerComponent('PlaylistMenuItem', PlaylistMenuItem)
export { PlaylistMenuItem }

View File

@ -0,0 +1,124 @@
import videojs from 'video.js'
import { VideoPlaylistElement } from '@shared/models'
import { PlaylistPluginOptions } from '../peertube-videojs-typings'
import { PlaylistMenuItem } from './playlist-menu-item'
const Component = videojs.getComponent('Component')
class PlaylistMenu extends Component {
private menuItems: PlaylistMenuItem[]
constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
super(player, options as any)
this.player().on('userinactive', () => {
this.close()
})
this.player().on('click', event => {
let current = event.target as HTMLElement
do {
if (
current.classList.contains('vjs-playlist-menu') ||
current.classList.contains('vjs-playlist-button')
) {
return
}
current = current.parentElement
} while (current)
this.close()
})
}
createEl () {
this.menuItems = []
const options = this.getOptions()
const menu = super.createEl('div', {
className: 'vjs-playlist-menu',
innerHTML: '',
tabIndex: -1
})
const header = super.createEl('div', {
className: 'header'
})
const headerLeft = super.createEl('div')
const leftTitle = super.createEl('div', {
innerHTML: options.playlist.displayName,
className: 'title'
})
const leftSubtitle = super.createEl('div', {
innerHTML: this.player().localize('By {1}', [ options.playlist.videoChannel.displayName ]),
className: 'channel'
})
headerLeft.appendChild(leftTitle)
headerLeft.appendChild(leftSubtitle)
const tick = super.createEl('div', {
className: 'cross'
})
tick.addEventListener('click', () => this.close())
header.appendChild(headerLeft)
header.appendChild(tick)
const list = super.createEl('ol')
for (const playlistElement of options.elements) {
const item = new PlaylistMenuItem(this.player(), {
element: playlistElement,
onClicked: () => this.onItemClicked(playlistElement)
})
list.appendChild(item.el())
this.menuItems.push(item)
}
menu.appendChild(header)
menu.appendChild(list)
return menu
}
update () {
const options = this.getOptions()
this.updateSelected(options.getCurrentPosition())
}
open () {
this.player().addClass('playlist-menu-displayed')
}
close () {
this.player().removeClass('playlist-menu-displayed')
}
updateSelected (newPosition: number) {
for (const item of this.menuItems) {
item.setSelected(item.getElement().position === newPosition)
}
}
private getOptions () {
return this.options_ as PlaylistPluginOptions
}
private onItemClicked (element: VideoPlaylistElement) {
this.getOptions().onItemClicked(element)
}
}
Component.registerComponent('PlaylistMenu', PlaylistMenu)
export { PlaylistMenu }

View File

@ -0,0 +1,35 @@
import videojs from 'video.js'
import { PlaylistPluginOptions } from '../peertube-videojs-typings'
import { PlaylistButton } from './playlist-button'
import { PlaylistMenu } from './playlist-menu'
const Plugin = videojs.getPlugin('plugin')
class PlaylistPlugin extends Plugin {
private playlistMenu: PlaylistMenu
private playlistButton: PlaylistButton
private options: PlaylistPluginOptions
constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
super(player, options)
this.options = options
this.player.ready(() => {
player.addClass('vjs-playlist')
})
this.playlistMenu = new PlaylistMenu(player, options)
this.playlistButton = new PlaylistButton(player, Object.assign({}, options, { playlistMenu: this.playlistMenu }))
player.addChild(this.playlistMenu, options)
player.addChild(this.playlistButton, options)
}
updateSelected () {
this.playlistMenu.updateSelected(this.options.getCurrentPosition())
}
}
videojs.registerPlugin('playlist', PlaylistPlugin)
export { PlaylistPlugin }

View File

@ -52,18 +52,7 @@ $play-overlay-width: 18px;
}
.icon {
width: 0;
height: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.5);
border-top: ($play-overlay-height / 2) solid transparent;
border-bottom: ($play-overlay-height / 2) solid transparent;
border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95);
@include play-icon($play-overlay-height, $play-overlay-width);
}
}

View File

@ -1019,3 +1019,18 @@
}
}
}
@mixin play-icon ($width, $height) {
width: 0;
height: 0;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.5);
border-top: ($height / 2) solid transparent;
border-bottom: ($height / 2) solid transparent;
border-left: $width solid rgba(255, 255, 255, 0.95);
}

View File

@ -4,4 +4,5 @@
@import './settings-menu';
@import './spinner';
@import './upnext';
@import './bezels.scss';
@import './bezels.scss';
@import './playlist.scss';

View File

@ -0,0 +1,165 @@
$playlist-menu-width: 350px;
.vjs-playlist-menu {
position: absolute;
right: 0;
height: 100%;
width: $playlist-menu-width;
background: rgba(0, 0, 0, 0.8);
z-index: 101;
transition: right 0.2s;
// Hidden
right: -$playlist-menu-width;
ol {
padding: 0;
margin: 0;
}
.header {
border-bottom: 1px solid $header-border-color;
padding: 20px 10px;
display: flex;
justify-content: space-between;
.title {
font-size: 14px;
margin-bottom: 5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.channel {
font-size: 11px;
color: #bfbfbf;
white-space: nowrap;
text-overflow: ellipsis;
}
.cross {
cursor: pointer;
width: 20px;
height: 20px;
mask-image: url('#{$assets-path}/images/feather/x.svg');
-webkit-mask-image: url('#{$assets-path}/images/feather/x.svg');
background-color: white;
mask-size: cover;
-webkit-mask-size: cover;
}
}
}
.playlist-menu-displayed {
.vjs-playlist-menu {
right: 0;
display: block;
}
.vjs-playlist-button {
display: none;
}
}
@media screen and (max-width: $playlist-menu-width) {
.vjs-playlist-menu {
width: 100%;
min-width: unset;
display: none;
}
.playlist-menu-displayed .vjs-playlist-menu {
display: block;
}
}
.vjs-playlist-button {
font-size: 15px;
position: absolute;
right: 0;
top: 0;
z-index: 100;
padding: 1em;
cursor: pointer;
}
.vjs-playlist-icon {
width: 22px;
height: 22px;
mask-image: url('#{$assets-path}/images/feather/list.svg');
-webkit-mask-image: url('#{$assets-path}/images/feather/list.svg');
background-color: white;
mask-size: cover;
-webkit-mask-size: cover;
margin-bottom: 3px;
}
.vjs-playing.vjs-user-inactive .vjs-playlist-button {
opacity: 0;
transition: opacity 1s;
}
.vjs-playing.vjs-no-flex.vjs-user-inactive .vjs-playlist-button {
display: none;
}
.vjs-playlist-menu-item {
cursor: pointer;
display: flex;
padding: 10px 0;
.item-position-block {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
}
.item-player {
display: none;
@include play-icon(20px, 16px);
}
&.vjs-selected {
background-color: rgba(150, 150, 150, 0.3);
.item-position {
display: none;
}
.item-player {
display: block;
}
}
&:hover {
background-color: rgba(150, 150, 150, 0.2);
}
img {
width: 80px;
height: 40px;
}
.info-block {
margin-left: 10px;
.title {
font-size: 13px;
margin-bottom: 5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.channel {
font-size: 11px;
color: #bfbfbf;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}

View File

@ -324,7 +324,11 @@ export class PeerTubeEmbed {
this.currentPlaylistElement = next
const res = await this.loadVideo(this.currentPlaylistElement.video.uuid)
return this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
}
private async loadVideoAndBuildPlayer (uuid: string) {
const res = await this.loadVideo(uuid)
if (res === undefined) return
return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
@ -386,6 +390,22 @@ export class PeerTubeEmbed {
this.loadParams(videoInfo)
const playlistPlugin = this.currentPlaylistElement
? {
elements: this.playlistElements,
playlist: this.playlist,
getCurrentPosition: () => this.currentPlaylistElement.position,
onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
this.currentPlaylistElement = videoPlaylistElement
this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
.catch(err => console.error(err))
}
}
: undefined
const options: PeertubePlayerManagerOptions = {
common: {
// Autoplay in playlist mode
@ -399,6 +419,7 @@ export class PeerTubeEmbed {
subtitle: this.subtitle,
nextVideo: () => this.autoplayNext(),
playlist: playlistPlugin,
videoCaptions,
inactivityTimeout: 2500,
@ -452,6 +473,7 @@ export class PeerTubeEmbed {
if (this.isPlaylistEmbed()) {
await this.buildPlaylistManager()
this.player.playlist().updateSelected()
}
}
@ -480,10 +502,7 @@ export class PeerTubeEmbed {
videoId = this.getResourceId()
}
const res = await this.loadVideo(videoId)
if (res === undefined) return
return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
return this.loadVideoAndBuildPlayer(videoId)
}
private handleError (err: Error, translations?: { [ id: string ]: string }) {

View File

@ -50,7 +50,9 @@ values(VIDEO_CATEGORIES)
'Sorry',
'This video is not available because the remote instance is not responding.',
'This playlist does not exist',
'We cannot fetch the playlist. Please try again later.'
'We cannot fetch the playlist. Please try again later.',
'Playlist: {1}',
'By {1}'
])
.forEach(v => { serverKeys[v] = v })