<?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>
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'
autoplay: boolean
nextVideo?: Function
+
+ playlist?: PlaylistPluginOptions
+
videoDuration: number
enableHotkeys: boolean
inactivityTimeout: number
}
}
+ if (commonOptions.playlist) {
+ plugins.playlist = commonOptions.playlist
+ }
+
if (commonOptions.enableHotkeys === true) {
PeertubePlayerManager.addHotkeysOptions(plugins)
}
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'
dock (options: { title: string, description: string }): void
upnext (options: Partial<EndCardOptions>): void
+
+ playlist (): PlaylistPlugin
}
}
stopTime: number | string
}
+type PlaylistPluginOptions = {
+ elements: VideoPlaylistElement[]
+
+ playlist: VideoPlaylist
+
+ getCurrentPosition: () => number
+
+ onItemClicked: (element: VideoPlaylistElement) => void
+}
+
type WebtorrentPluginOptions = {
playerElement: HTMLVideoElement
}
type VideoJSPluginOptions = {
+ playlist?: PlaylistPluginOptions
+
peertube: PeerTubePluginOptions
webtorrent?: WebtorrentPluginOptions
}
}
+type PlaylistItemOptions = {
+ element: VideoPlaylistElement
+
+ onClicked: Function
+}
+
export {
PlayerNetworkInfo,
+ PlaylistItemOptions,
ResolutionUpdateData,
AutoResolutionUpdateData,
+ PlaylistPluginOptions,
VideoJSCaption,
UserWatching,
PeerTubePluginOptions,
--- /dev/null
+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 }
--- /dev/null
+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 }
--- /dev/null
+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 }
--- /dev/null
+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 }
}
.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);
}
}
}
}
}
+
+@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);
+}
@import './settings-menu';
@import './spinner';
@import './upnext';
-@import './bezels.scss';
\ No newline at end of file
+@import './bezels.scss';
+@import './playlist.scss';
--- /dev/null
+$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;
+ }
+ }
+}
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)
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
subtitle: this.subtitle,
nextVideo: () => this.autoplayNext(),
+ playlist: playlistPlugin,
videoCaptions,
inactivityTimeout: 2500,
if (this.isPlaylistEmbed()) {
await this.buildPlaylistManager()
+ this.player.playlist().updateSelected()
}
}
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 }) {
'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 })