From 4572c3d0d92f5b1b79b34dbe2c7b6557a8a5b7e4 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 5 Aug 2020 09:44:58 +0200 Subject: [PATCH] Handle basic playlist in embed --- .../src/assets/player/images/tick-white.svg | 5 +- .../assets/player/peertube-player-manager.ts | 18 +- .../assets/player/peertube-videojs-typings.ts | 25 ++- .../assets/player/playlist/playlist-button.ts | 61 +++++++ .../player/playlist/playlist-menu-item.ts | 98 +++++++++++ .../assets/player/playlist/playlist-menu.ts | 124 +++++++++++++ .../assets/player/playlist/playlist-plugin.ts | 35 ++++ client/src/sass/include/_miniature.scss | 13 +- client/src/sass/include/_mixins.scss | 15 ++ client/src/sass/player/index.scss | 3 +- client/src/sass/player/playlist.scss | 165 ++++++++++++++++++ client/src/standalone/videos/embed.ts | 29 ++- scripts/i18n/create-custom-files.ts | 4 +- 13 files changed, 570 insertions(+), 25 deletions(-) create mode 100644 client/src/assets/player/playlist/playlist-button.ts create mode 100644 client/src/assets/player/playlist/playlist-menu-item.ts create mode 100644 client/src/assets/player/playlist/playlist-menu.ts create mode 100644 client/src/assets/player/playlist/playlist-plugin.ts create mode 100644 client/src/sass/player/playlist.scss diff --git a/client/src/assets/player/images/tick-white.svg b/client/src/assets/player/images/tick-white.svg index d329e6bfb..8868a2481 100644 --- a/client/src/assets/player/images/tick-white.svg +++ b/client/src/assets/player/images/tick-white.svg @@ -1,8 +1,7 @@ - - - + + diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 6a6d63462..dcfa3a593 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -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) } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 1506a04ac..b72c4b0f9 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -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): 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, diff --git a/client/src/assets/player/playlist/playlist-button.ts b/client/src/assets/player/playlist/playlist-button.ts new file mode 100644 index 000000000..a7996ec60 --- /dev/null +++ b/client/src/assets/player/playlist/playlist-button.ts @@ -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 } diff --git a/client/src/assets/player/playlist/playlist-menu-item.ts b/client/src/assets/player/playlist/playlist-menu-item.ts new file mode 100644 index 000000000..916c6338f --- /dev/null +++ b/client/src/assets/player/playlist/playlist-menu-item.ts @@ -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 } diff --git a/client/src/assets/player/playlist/playlist-menu.ts b/client/src/assets/player/playlist/playlist-menu.ts new file mode 100644 index 000000000..7d7d9e12f --- /dev/null +++ b/client/src/assets/player/playlist/playlist-menu.ts @@ -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 } diff --git a/client/src/assets/player/playlist/playlist-plugin.ts b/client/src/assets/player/playlist/playlist-plugin.ts new file mode 100644 index 000000000..b69d82e3c --- /dev/null +++ b/client/src/assets/player/playlist/playlist-plugin.ts @@ -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 } diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss index 976bbf4d6..97b4c690b 100644 --- a/client/src/sass/include/_miniature.scss +++ b/client/src/sass/include/_miniature.scss @@ -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); } } diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index ee2fe0497..e4c2dffa0 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -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); +} diff --git a/client/src/sass/player/index.scss b/client/src/sass/player/index.scss index 58ce3ac96..fe92ce5e0 100644 --- a/client/src/sass/player/index.scss +++ b/client/src/sass/player/index.scss @@ -4,4 +4,5 @@ @import './settings-menu'; @import './spinner'; @import './upnext'; -@import './bezels.scss'; \ No newline at end of file +@import './bezels.scss'; +@import './playlist.scss'; diff --git a/client/src/sass/player/playlist.scss b/client/src/sass/player/playlist.scss new file mode 100644 index 000000000..c242acba8 --- /dev/null +++ b/client/src/sass/player/playlist.scss @@ -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; + } + } +} diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 71bd04e76..17b0ee9ef 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -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 }) { diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 298eda71b..89a967b14 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts @@ -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 }) -- 2.41.0