From c6352f2c64f3c1ad54f8500f493587cdce3d33c9 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 30 Mar 2018 17:40:00 +0200 Subject: Improve player Add a settings dialog based on the work of Yanko Shterev (@yshterev): https://github.com/yshterev/videojs-settings-menu. Thanks! --- client/src/assets/player/images/settings.svg | 14 + client/src/assets/player/images/tick.svg | 12 + client/src/assets/player/peertube-link-button.ts | 20 ++ client/src/assets/player/peertube-player.ts | 96 +++++++ .../src/assets/player/peertube-videojs-plugin.ts | 288 ++++--------------- .../src/assets/player/peertube-videojs-typings.ts | 33 +++ client/src/assets/player/resolution-menu-button.ts | 68 +++++ client/src/assets/player/resolution-menu-item.ts | 31 ++ client/src/assets/player/settings-menu-button.ts | 285 +++++++++++++++++++ client/src/assets/player/settings-menu-item.ts | 313 +++++++++++++++++++++ client/src/assets/player/utils.ts | 72 +++++ client/src/assets/player/webtorrent-info-button.ts | 101 +++++++ 12 files changed, 1100 insertions(+), 233 deletions(-) create mode 100644 client/src/assets/player/images/settings.svg create mode 100644 client/src/assets/player/images/tick.svg create mode 100644 client/src/assets/player/peertube-link-button.ts create mode 100644 client/src/assets/player/peertube-player.ts create mode 100644 client/src/assets/player/peertube-videojs-typings.ts create mode 100644 client/src/assets/player/resolution-menu-button.ts create mode 100644 client/src/assets/player/resolution-menu-item.ts create mode 100644 client/src/assets/player/settings-menu-button.ts create mode 100644 client/src/assets/player/settings-menu-item.ts create mode 100644 client/src/assets/player/utils.ts create mode 100644 client/src/assets/player/webtorrent-info-button.ts (limited to 'client/src/assets/player') diff --git a/client/src/assets/player/images/settings.svg b/client/src/assets/player/images/settings.svg new file mode 100644 index 000000000..c663087b7 --- /dev/null +++ b/client/src/assets/player/images/settings.svg @@ -0,0 +1,14 @@ + + + + settings + Created with Sketch. + + + + + + + + + diff --git a/client/src/assets/player/images/tick.svg b/client/src/assets/player/images/tick.svg new file mode 100644 index 000000000..d329e6bfb --- /dev/null +++ b/client/src/assets/player/images/tick.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts new file mode 100644 index 000000000..6ead78c00 --- /dev/null +++ b/client/src/assets/player/peertube-link-button.ts @@ -0,0 +1,20 @@ +import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +class PeerTubeLinkButton extends Button { + + createEl () { + return videojsUntyped.dom.createEl('a', { + href: window.location.href.replace('embed', 'watch'), + innerHTML: 'PeerTube', + title: 'Go to the video page', + className: 'vjs-peertube-link', + target: '_blank' + }) + } + + handleClick () { + this.player_.pause() + } +} +Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts new file mode 100644 index 000000000..4ae3e71bd --- /dev/null +++ b/client/src/assets/player/peertube-player.ts @@ -0,0 +1,96 @@ +import { VideoFile } from '../../../../shared/models/videos' + +import 'videojs-hotkeys' +import 'videojs-dock/dist/videojs-dock.es.js' +import './peertube-link-button' +import './resolution-menu-button' +import './settings-menu-button' +import './webtorrent-info-button' +import './peertube-videojs-plugin' +import { videojsUntyped } from './peertube-videojs-typings' + +// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) +videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' + +function getVideojsOptions (options: { + autoplay: boolean, + playerElement: HTMLVideoElement, + videoViewUrl: string, + videoDuration: number, + videoFiles: VideoFile[], + enableHotkeys: boolean, + inactivityTimeout: number, + peertubeLink: boolean +}) { + const videojsOptions = { + controls: true, + autoplay: options.autoplay, + inactivityTimeout: options.inactivityTimeout, + playbackRates: [ 0.5, 1, 1.5, 2 ], + plugins: { + peertube: { + videoFiles: options.videoFiles, + playerElement: options.playerElement, + videoViewUrl: options.videoViewUrl, + videoDuration: options.videoDuration + } + }, + controlBar: { + children: getControlBarChildren(options) + } + } + + if (options.enableHotkeys === true) { + Object.assign(videojsOptions.plugins, { + hotkeys: { + enableVolumeScroll: false + } + }) + } + + return videojsOptions +} + +function getControlBarChildren (options: { + peertubeLink: boolean +}) { + const children = { + 'playToggle': {}, + 'currentTimeDisplay': {}, + 'timeDivider': {}, + 'durationDisplay': {}, + 'liveDisplay': {}, + + 'flexibleWidthSpacer': {}, + 'progressControl': {}, + + 'webTorrentButton': {}, + + 'muteToggle': {}, + 'volumeControl': {}, + + 'settingsButton': { + setup: { + maxHeightOffset: 40 + }, + entries: [ + 'resolutionMenuButton', + 'playbackRateMenuButton' + ] + } + } + + if (options.peertubeLink === true) { + Object.assign(children, { + 'peerTubeLinkButton': {} + }) + } + + Object.assign(children, { + 'fullscreenToggle': {} + }) + + return children +} + +export { getVideojsOptions } diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 22cb27da3..c35ce12cb 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -1,49 +1,11 @@ -// Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher - import * as videojs from 'video.js' import * as WebTorrent from 'webtorrent' -import { VideoConstant, VideoResolution } from '../../../../shared/models/videos' import { VideoFile } from '../../../../shared/models/videos/video.model' import { renderVideo } from './video-renderer' +import './settings-menu-button' +import { PeertubePluginOptions, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +import { getStoredMute, getStoredVolume, saveMuteInStore, saveVolumeInStore } from './utils' -declare module 'video.js' { - interface Player { - peertube (): PeerTubePlugin - } -} - -interface VideoJSComponentInterface { - _player: videojs.Player - - new (player: videojs.Player, options?: any) - - registerComponent (name: string, obj: any) -} - -type PeertubePluginOptions = { - videoFiles: VideoFile[] - playerElement: HTMLVideoElement - videoViewUrl: string - videoDuration: number -} - -// https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts -// Don't import all Angular stuff, just copy the code with shame -const dictionaryBytes: Array<{max: number, type: string}> = [ - { max: 1024, type: 'B' }, - { max: 1048576, type: 'KB' }, - { max: 1073741824, type: 'MB' }, - { max: 1.0995116e12, type: 'GB' } -] -function bytes (value) { - const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] - const calc = Math.floor(value / (format.max / 1024)).toString() - - return [ calc, format.type ] -} - -// videojs typings don't have some method we need -const videojsUntyped = videojs as any const webtorrent = new WebTorrent({ tracker: { rtcConfig: { @@ -60,199 +22,19 @@ const webtorrent = new WebTorrent({ dht: false }) -const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') -class ResolutionMenuItem extends MenuItem { - - constructor (player: videojs.Player, options) { - options.selectable = true - super(player, options) - - const currentResolutionId = this.player_.peertube().getCurrentResolutionId() - this.selected(this.options_.id === currentResolutionId) - } - - handleClick (event) { - super.handleClick(event) - - this.player_.peertube().updateResolution(this.options_.id) - } -} -MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) - -const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') -class ResolutionMenuButton extends MenuButton { - label: HTMLElement - - constructor (player: videojs.Player, options) { - options.label = 'Quality' - super(player, options) - - this.label = document.createElement('span') - - this.el().setAttribute('aria-label', 'Quality') - this.controlText('Quality') - - videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label') - this.el().appendChild(this.label) - - player.peertube().on('videoFileUpdate', () => this.update()) - } - - createItems () { - const menuItems = [] - for (const videoFile of this.player_.peertube().videoFiles) { - menuItems.push(new ResolutionMenuItem( - this.player_, - { - id: videoFile.resolution.id, - label: videoFile.resolution.label, - src: videoFile.magnetUri, - selected: videoFile.resolution.id === this.currentSelectionId - }) - ) - } - - return menuItems - } - - update () { - if (!this.label) return - - this.label.innerHTML = this.player_.peertube().getCurrentResolutionLabel() - this.hide() - return super.update() - } - - buildCSSClass () { - return super.buildCSSClass() + ' vjs-resolution-button' - } - - buildWrapperCSSClass () { - return 'vjs-resolution-control ' + super.buildWrapperCSSClass() - } -} -MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) - -const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') -class PeerTubeLinkButton extends Button { - - createEl () { - const link = document.createElement('a') - link.href = window.location.href.replace('embed', 'watch') - link.innerHTML = 'PeerTube' - link.title = 'Go to the video page' - link.className = 'vjs-peertube-link' - link.target = '_blank' - - return link - } - - handleClick () { - this.player_.pause() - } -} -Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) - -class WebTorrentButton extends Button { - createEl () { - const div = document.createElement('div') - const subDivWebtorrent = document.createElement('div') - div.appendChild(subDivWebtorrent) - - const downloadIcon = document.createElement('span') - downloadIcon.classList.add('icon', 'icon-download') - subDivWebtorrent.appendChild(downloadIcon) - - const downloadSpeedText = document.createElement('span') - downloadSpeedText.classList.add('download-speed-text') - const downloadSpeedNumber = document.createElement('span') - downloadSpeedNumber.classList.add('download-speed-number') - const downloadSpeedUnit = document.createElement('span') - downloadSpeedText.appendChild(downloadSpeedNumber) - downloadSpeedText.appendChild(downloadSpeedUnit) - subDivWebtorrent.appendChild(downloadSpeedText) - - const uploadIcon = document.createElement('span') - uploadIcon.classList.add('icon', 'icon-upload') - subDivWebtorrent.appendChild(uploadIcon) - - const uploadSpeedText = document.createElement('span') - uploadSpeedText.classList.add('upload-speed-text') - const uploadSpeedNumber = document.createElement('span') - uploadSpeedNumber.classList.add('upload-speed-number') - const uploadSpeedUnit = document.createElement('span') - uploadSpeedText.appendChild(uploadSpeedNumber) - uploadSpeedText.appendChild(uploadSpeedUnit) - subDivWebtorrent.appendChild(uploadSpeedText) - - const peersText = document.createElement('span') - peersText.classList.add('peers-text') - const peersNumber = document.createElement('span') - peersNumber.classList.add('peers-number') - subDivWebtorrent.appendChild(peersNumber) - subDivWebtorrent.appendChild(peersText) - - div.className = 'vjs-peertube' - // Hide the stats before we get the info - subDivWebtorrent.className = 'vjs-peertube-hidden' - - const subDivHttp = document.createElement('div') - subDivHttp.className = 'vjs-peertube-hidden' - const subDivHttpText = document.createElement('span') - subDivHttpText.classList.add('peers-number') - subDivHttpText.textContent = 'HTTP' - const subDivFallbackText = document.createElement('span') - subDivFallbackText.classList.add('peers-text') - subDivFallbackText.textContent = ' fallback' - - subDivHttp.appendChild(subDivHttpText) - subDivHttp.appendChild(subDivFallbackText) - div.appendChild(subDivHttp) - - this.player_.peertube().on('torrentInfo', (event, data) => { - // We are in HTTP fallback - if (!data) { - subDivHttp.className = 'vjs-peertube-displayed' - subDivWebtorrent.className = 'vjs-peertube-hidden' - - return - } - - const downloadSpeed = bytes(data.downloadSpeed) - const uploadSpeed = bytes(data.uploadSpeed) - const numPeers = data.numPeers - - downloadSpeedNumber.textContent = downloadSpeed[ 0 ] - downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] - - uploadSpeedNumber.textContent = uploadSpeed[ 0 ] - uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] - - peersNumber.textContent = numPeers - peersText.textContent = ' peers' - - subDivHttp.className = 'vjs-peertube-hidden' - subDivWebtorrent.className = 'vjs-peertube-displayed' - }) - - return div - } -} -Button.registerComponent('WebTorrentButton', WebTorrentButton) - const Plugin: VideoJSComponentInterface = videojsUntyped.getPlugin('plugin') class PeerTubePlugin extends Plugin { + private readonly playerElement: HTMLVideoElement + private readonly autoplay: boolean = false + private readonly savePlayerSrcFunction: Function private player: any private currentVideoFile: VideoFile - private playerElement: HTMLVideoElement private videoFiles: VideoFile[] private torrent: WebTorrent.Torrent - private autoplay = false private videoViewUrl: string private videoDuration: number private videoViewInterval private torrentInfoInterval - private savePlayerSrcFunction: Function constructor (player: videojs.Player, options: PeertubePluginOptions) { super(player, options) @@ -274,10 +56,20 @@ class PeerTubePlugin extends Plugin { this.playerElement = options.playerElement this.player.ready(() => { + const volume = getStoredVolume() + if (volume !== undefined) this.player.volume(volume) + const muted = getStoredMute() + if (muted !== undefined) this.player.muted(muted) + this.initializePlayer() this.runTorrentInfoScheduler() this.runViewAdd() }) + + this.player.on('volumechange', () => { + saveVolumeInStore(this.player.volume()) + saveMuteInStore(this.player.muted()) + }) } dispose () { @@ -311,16 +103,19 @@ class PeerTubePlugin extends Plugin { return } - // Do not display error to user because we will have multiple fallbacks + // Do not display error to user because we will have multiple fallback this.disableErrorDisplay() this.player.src = () => true - this.player.playbackRate(1) + const oldPlaybackRate = this.player.playbackRate() const previousVideoFile = this.currentVideoFile this.currentVideoFile = videoFile - this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, done) + this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) this.trigger('videoFileUpdate') } @@ -337,7 +132,7 @@ class PeerTubePlugin extends Plugin { renderVideo(torrent.files[0], this.playerElement, options,(err, renderer) => { this.renderer = renderer - if (err) return this.fallbackToHttp() + if (err) return this.fallbackToHttp(done) if (!this.player.paused()) { const playPromise = this.player.play() @@ -414,13 +209,17 @@ class PeerTubePlugin extends Plugin { private initializePlayer () { this.initSmoothProgressBar() + this.alterInactivity() + if (this.autoplay === true) { this.updateVideoFile(undefined, () => this.player.play()) } else { - this.player.one('play', () => { - this.player.pause() - this.updateVideoFile(undefined, () => this.player.play()) - }) + // Proxify first play + const oldPlay = this.player.play.bind(this.player) + this.player.play = () => { + this.updateVideoFile(undefined, () => oldPlay) + this.player.play = oldPlay + } } } @@ -473,7 +272,7 @@ class PeerTubePlugin extends Plugin { return fetch(this.videoViewUrl, { method: 'POST' }) } - private fallbackToHttp () { + private fallbackToHttp (done: Function) { this.flushVideoFile(this.currentVideoFile, true) this.torrent = null @@ -484,6 +283,8 @@ class PeerTubePlugin extends Plugin { this.player.src = this.savePlayerSrcFunction this.player.src(httpUrl) this.player.play() + + return done() } private handleError (err: Error | string) { @@ -498,6 +299,25 @@ class PeerTubePlugin extends Plugin { this.player.removeClass('vjs-error-display-enabled') } + private alterInactivity () { + let saveInactivityTimeout: number + + const disableInactivity = () => { + saveInactivityTimeout = this.player.options_.inactivityTimeout + this.player.options_.inactivityTimeout = 0 + } + const enableInactivity = () => { + // this.player.options_.inactivityTimeout = saveInactivityTimeout + } + + const settingsDialog = this.player.children_.find(c => c.name_ === 'SettingsDialog') + + this.player.controlBar.on('mouseenter', () => disableInactivity()) + settingsDialog.on('mouseenter', () => disableInactivity()) + this.player.controlBar.on('mouseleave', () => enableInactivity()) + settingsDialog.on('mouseleave', () => enableInactivity()) + } + // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 private initSmoothProgressBar () { const SeekBar = videojsUntyped.getComponent('SeekBar') @@ -520,4 +340,6 @@ class PeerTubePlugin extends Plugin { } } } + videojsUntyped.registerPlugin('peertube', PeerTubePlugin) +export { PeerTubePlugin } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts new file mode 100644 index 000000000..a58fa6505 --- /dev/null +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -0,0 +1,33 @@ +import * as videojs from 'video.js' +import { VideoFile } from '../../../../shared/models/videos/video.model' +import { PeerTubePlugin } from './peertube-videojs-plugin' + +declare module 'video.js' { + interface Player { + peertube (): PeerTubePlugin + } +} + +interface VideoJSComponentInterface { + _player: videojs.Player + + new (player: videojs.Player, options?: any) + + registerComponent (name: string, obj: any) +} + +type PeertubePluginOptions = { + videoFiles: VideoFile[] + playerElement: HTMLVideoElement + videoViewUrl: string + videoDuration: number +} + +// videojs typings don't have some method we need +const videojsUntyped = videojs as any + +export { + VideoJSComponentInterface, + PeertubePluginOptions, + videojsUntyped +} diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts new file mode 100644 index 000000000..c927b084d --- /dev/null +++ b/client/src/assets/player/resolution-menu-button.ts @@ -0,0 +1,68 @@ +import * as videojs from 'video.js' +import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +import { ResolutionMenuItem } from './resolution-menu-item' + +const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') +const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') +class ResolutionMenuButton extends MenuButton { + label: HTMLElement + + constructor (player: videojs.Player, options) { + options.label = 'Quality' + super(player, options) + + this.controlText_ = 'Quality' + this.player = player + + player.peertube().on('videoFileUpdate', () => this.updateLabel()) + } + + createEl () { + const el = super.createEl() + + this.labelEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-resolution-value', + innerHTML: this.player_.peertube().getCurrentResolutionLabel() + }) + + el.appendChild(this.labelEl_) + + return el + } + + updateARIAAttributes () { + this.el().setAttribute('aria-label', 'Quality') + } + + createMenu () { + const menu = new Menu(this.player()) + + for (const videoFile of this.player_.peertube().videoFiles) { + menu.addChild(new ResolutionMenuItem( + this.player_, + { + id: videoFile.resolution.id, + label: videoFile.resolution.label, + src: videoFile.magnetUri + }) + ) + } + + return menu + } + + updateLabel () { + if (!this.labelEl_) return + + this.labelEl_.innerHTML = this.player_.peertube().getCurrentResolutionLabel() + } + + buildCSSClass () { + return super.buildCSSClass() + ' vjs-resolution-button' + } + + buildWrapperCSSClass () { + return 'vjs-resolution-control ' + super.buildWrapperCSSClass() + } +} +MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts new file mode 100644 index 000000000..95e0ed1f8 --- /dev/null +++ b/client/src/assets/player/resolution-menu-item.ts @@ -0,0 +1,31 @@ +import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' + +const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') +class ResolutionMenuItem extends MenuItem { + + constructor (player: videojs.Player, options) { + const currentResolutionId = player.peertube().getCurrentResolutionId() + options.selectable = true + options.selected = options.id === currentResolutionId + + super(player, options) + + this.label = options.label + this.id = options.id + + player.peertube().on('videoFileUpdate', () => this.update()) + } + + handleClick (event) { + super.handleClick(event) + + this.player_.peertube().updateResolution(this.id) + } + + update () { + this.selected(this.player_.peertube().getCurrentResolutionId() === this.id) + } +} +MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) + +export { ResolutionMenuItem } diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts new file mode 100644 index 000000000..c48e1382c --- /dev/null +++ b/client/src/assets/player/settings-menu-button.ts @@ -0,0 +1,285 @@ +// Author: Yanko Shterev +// Thanks https://github.com/yshterev/videojs-settings-menu + +import * as videojs from 'video.js' +import { SettingsMenuItem } from './settings-menu-item' +import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +import { toTitleCase } from './utils' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') +const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') + +class SettingsButton extends Button { + constructor (player: videojs.Player, options) { + super(player, options) + + this.playerComponent = player + this.dialog = this.playerComponent.addChild('settingsDialog') + this.dialogEl = this.dialog.el_ + this.menu = null + this.panel = this.dialog.addChild('settingsPanel') + this.panelChild = this.panel.addChild('settingsPanelChild') + + this.addClass('vjs-settings') + this.el_.setAttribute('aria-label', 'Settings Button') + + // Event handlers + this.addSettingsItemHandler = this.onAddSettingsItem.bind(this) + this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this) + this.playerClickHandler = this.onPlayerClick.bind(this) + this.userInactiveHandler = this.onUserInactive.bind(this) + + this.buildMenu() + this.bindEvents() + + // Prepare dialog + this.player().one('play', () => this.hideDialog()) + } + + onPlayerClick (event: MouseEvent) { + const element = event.target as HTMLElement + if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) { + return + } + + if (!this.dialog.hasClass('vjs-hidden')) { + this.hideDialog() + } + } + + onDisposeSettingsItem (event, name: string) { + if (name === undefined) { + let children = this.menu.children() + + while (children.length > 0) { + children[0].dispose() + this.menu.removeChild(children[0]) + } + + this.addClass('vjs-hidden') + } else { + let item = this.menu.getChild(name) + + if (item) { + item.dispose() + this.menu.removeChild(item) + } + } + + this.hideDialog() + + if (this.options_.entries.length === 0) { + this.addClass('vjs-hidden') + } + } + + onAddSettingsItem (event, data) { + const [ entry, options ] = data + + this.addMenuItem(entry, options) + this.removeClass('vjs-hidden') + } + + onUserInactive () { + if (!this.dialog.hasClass('vjs-hidden')) { + this.hideDialog() + } + } + + bindEvents () { + this.playerComponent.on('click', this.playerClickHandler) + this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler) + this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler) + this.playerComponent.on('userinactive', this.userInactiveHandler) + } + + buildCSSClass () { + return `vjs-icon-settings ${super.buildCSSClass()}` + } + + handleClick () { + if (this.dialog.hasClass('vjs-hidden')) { + this.showDialog() + } else { + this.hideDialog() + } + } + + showDialog () { + this.menu.el_.style.opacity = '1' + this.dialog.show() + + this.setDialogSize(this.getComponentSize(this.menu)) + } + + hideDialog () { + this.dialog.hide() + this.setDialogSize(this.getComponentSize(this.menu)) + this.menu.el_.style.opacity = '1' + this.resetChildren() + } + + getComponentSize (element) { + let width: number = null + let height: number = null + + // Could be component or just DOM element + if (element instanceof Component) { + width = element.el_.offsetWidth + height = element.el_.offsetHeight + + // keep width/height as properties for direct use + element.width = width + element.height = height + } else { + width = element.offsetWidth + height = element.offsetHeight + } + + return [ width, height ] + } + + setDialogSize ([ width, height ]: number[]) { + if (typeof height !== 'number') { + return + } + + let offset = this.options_.setup.maxHeightOffset + let maxHeight = this.playerComponent.el_.offsetHeight - offset + + if (height > maxHeight) { + height = maxHeight + width += 17 + this.panel.el_.style.maxHeight = `${height}px` + } else if (this.panel.el_.style.maxHeight !== '') { + this.panel.el_.style.maxHeight = '' + } + + this.dialogEl.style.width = `${width}px` + this.dialogEl.style.height = `${height}px` + } + + buildMenu () { + this.menu = new Menu(this.player()) + this.menu.addClass('vjs-main-menu') + let entries = this.options_.entries + + if (entries.length === 0) { + this.addClass('vjs-hidden') + this.panelChild.addChild(this.menu) + return + } + + for (let entry of entries) { + this.addMenuItem(entry, this.options_) + } + + this.panelChild.addChild(this.menu) + } + + addMenuItem (entry, options) { + const openSubMenu = function () { + if (videojsUntyped.dom.hasClass(this.el_, 'open')) { + videojsUntyped.dom.removeClass(this.el_, 'open') + } else { + videojsUntyped.dom.addClass(this.el_, 'open') + } + } + + options.name = toTitleCase(entry) + let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any) + + this.menu.addChild(settingsMenuItem) + + // Hide children to avoid sub menus stacking on top of each other + // or having multiple menus open + settingsMenuItem.on('click', videojs.bind(this, this.hideChildren)) + + // Whether to add or remove selected class on the settings sub menu element + settingsMenuItem.on('click', openSubMenu) + } + + resetChildren () { + for (let menuChild of this.menu.children()) { + menuChild.reset() + } + } + + /** + * Hide all the sub menus + */ + hideChildren () { + for (let menuChild of this.menu.children()) { + menuChild.hideSubMenu() + } + } + +} + +class SettingsPanel extends Component { + constructor (player: videojs.Player, options) { + super(player, options) + } + + createEl () { + return super.createEl('div', { + className: 'vjs-settings-panel', + innerHTML: '', + tabIndex: -1 + }) + } +} + +class SettingsPanelChild extends Component { + constructor (player: videojs.Player, options) { + super(player, options) + } + + createEl () { + return super.createEl('div', { + className: 'vjs-settings-panel-child', + innerHTML: '', + tabIndex: -1 + }) + } +} + +class SettingsDialog extends Component { + constructor (player: videojs.Player, options) { + super(player, options) + this.hide() + } + + /** + * Create the component's DOM element + * + * @return {Element} + * @method createEl + */ + createEl () { + const uniqueId = this.id_ + const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId + const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId + + return super.createEl('div', { + className: 'vjs-settings-dialog vjs-modal-overlay', + innerHTML: '', + tabIndex: -1 + }, { + 'role': 'dialog', + 'aria-labelledby': dialogLabelId, + 'aria-describedby': dialogDescriptionId + }) + } + +} + +SettingsButton.prototype.controlText_ = 'Settings Button' + +Component.registerComponent('SettingsButton', SettingsButton) +Component.registerComponent('SettingsDialog', SettingsDialog) +Component.registerComponent('SettingsPanel', SettingsPanel) +Component.registerComponent('SettingsPanelChild', SettingsPanelChild) + +export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild } diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts new file mode 100644 index 000000000..e979ae088 --- /dev/null +++ b/client/src/assets/player/settings-menu-item.ts @@ -0,0 +1,313 @@ +// Author: Yanko Shterev +// Thanks https://github.com/yshterev/videojs-settings-menu + +import { toTitleCase } from './utils' +import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' + +const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') +const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') + +class SettingsMenuItem extends MenuItem { + + constructor (player: videojs.Player, options, entry: string, menuButton: VideoJSComponentInterface) { + super(player, options) + + this.settingsButton = menuButton + this.dialog = this.settingsButton.dialog + this.mainMenu = this.settingsButton.menu + this.panel = this.dialog.getChild('settingsPanel') + this.panelChild = this.panel.getChild('settingsPanelChild') + this.panelChildEl = this.panelChild.el_ + + this.size = null + + // keep state of what menu type is loading next + this.menuToLoad = 'mainmenu' + + const subMenuName = toTitleCase(entry) + const SubMenuComponent = videojsUntyped.getComponent(subMenuName) + + if (!SubMenuComponent) { + throw new Error(`Component ${subMenuName} does not exist`) + } + this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) + + this.eventHandlers() + + player.ready(() => { + this.build() + this.reset() + }) + } + + eventHandlers () { + this.submenuClickHandler = this.onSubmenuClick.bind(this) + this.transitionEndHandler = this.onTransitionEnd.bind(this) + } + + onSubmenuClick (event) { + let target = null + + if (event.type === 'tap') { + target = event.target + } else { + target = event.currentTarget + } + + if (target.classList.contains('vjs-back-button')) { + this.loadMainMenu() + return + } + + // To update the sub menu value on click, setTimeout is needed because + // updating the value is not instant + setTimeout(() => this.update(event), 0) + } + + /** + * Create the component's DOM element + * + * @return {Element} + * @method createEl + */ + createEl () { + const el = videojsUntyped.dom.createEl('li', { + className: 'vjs-menu-item' + }) + + this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-settings-sub-menu-title' + }) + + el.appendChild(this.settingsSubMenuTitleEl_) + + this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-settings-sub-menu-value' + }) + + el.appendChild(this.settingsSubMenuValueEl_) + + this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', { + className: 'vjs-settings-sub-menu' + }) + + return el + } + + /** + * Handle click on menu item + * + * @method handleClick + */ + handleClick () { + this.menuToLoad = 'submenu' + // Remove open class to ensure only the open submenu gets this class + videojsUntyped.dom.removeClass(this.el_, 'open') + + super.handleClick() + + this.mainMenu.el_.style.opacity = '0' + // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element + if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { + videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') + + // animation not played without timeout + setTimeout(() => { + this.settingsSubMenuEl_.style.opacity = '1' + this.settingsSubMenuEl_.style.marginRight = '0px' + }, 0) + + this.settingsButton.setDialogSize(this.size) + } else { + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + } + } + + /** + * Create back button + * + * @method createBackButton + */ + createBackButton () { + const button = this.subMenu.menu.addChild('MenuItem', {}, 0) + button.name_ = 'BackButton' + button.addClass('vjs-back-button') + button.el_.innerHTML = this.subMenu.controlText_ + } + + /** + * Add/remove prefixed event listener for CSS Transition + * + * @method PrefixedEvent + */ + PrefixedEvent (element, type, callback, action = 'addEvent') { + let prefix = ['webkit', 'moz', 'MS', 'o', ''] + + for (let p = 0; p < prefix.length; p++) { + if (!prefix[p]) { + type = type.toLowerCase() + } + + if (action === 'addEvent') { + element.addEventListener(prefix[p] + type, callback, false) + } else if (action === 'removeEvent') { + element.removeEventListener(prefix[p] + type, callback, false) + } + } + } + + onTransitionEnd (event) { + if (event.propertyName !== 'margin-right') { + return + } + + if (this.menuToLoad === 'mainmenu') { + // hide submenu + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + + // reset opacity to 0 + this.settingsSubMenuEl_.style.opacity = '0' + } + } + + reset () { + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + this.settingsSubMenuEl_.style.opacity = '0' + this.setMargin() + } + + loadMainMenu () { + this.menuToLoad = 'mainmenu' + this.mainMenu.show() + this.mainMenu.el_.style.opacity = '0' + + // back button will always take you to main menu, so set dialog sizes + this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height]) + + // animation not triggered without timeout (some async stuff ?!?) + setTimeout(() => { + // animate margin and opacity before hiding the submenu + // this triggers CSS Transition event + this.setMargin() + this.mainMenu.el_.style.opacity = '1' + }, 0) + } + + build () { + const saveUpdateLabel = this.subMenu.updateLabel + this.subMenu.updateLabel = () => { + this.update() + + saveUpdateLabel.call(this.subMenu) + } + + this.settingsSubMenuTitleEl_.innerHTML = this.subMenu.controlText_ + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) + this.panelChildEl.appendChild(this.settingsSubMenuEl_) + this.update() + + this.createBackButton() + this.getSize() + this.bindClickEvents() + + // prefixed event listeners for CSS TransitionEnd + this.PrefixedEvent( + this.settingsSubMenuEl_, + 'TransitionEnd', + this.transitionEndHandler, + 'addEvent' + ) + } + + update (event?: Event) { + let target = null + let subMenu = this.subMenu.name() + + if (event && event.type === 'tap') { + target = event.target + } else if (event) { + target = event.currentTarget + } + + // Playback rate menu button doesn't get a vjs-selected class + // or sets options_['selected'] on the selected playback rate. + // Thus we get the submenu value based on the labelEl of playbackRateMenuButton + if (subMenu === 'PlaybackRateMenuButton') { + setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250) + } else { + // Loop trough the submenu items to find the selected child + for (let subMenuItem of this.subMenu.menu.children_) { + if (!(subMenuItem instanceof component)) { + continue + } + + switch (subMenu) { + case 'SubtitlesButton': + case 'CaptionsButton': + // subtitlesButton entering default check twice and overwriting + // selected label in main manu + if (subMenuItem.hasClass('vjs-selected')) { + this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label + } + break + + default: + // Set submenu value based on what item is selected + if (subMenuItem.options_.selected || subMenuItem.hasClass('vjs-selected')) { + this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label + } + } + } + } + + if (target && !target.classList.contains('vjs-back-button')) { + this.settingsButton.hideDialog() + } + } + + bindClickEvents () { + for (let item of this.subMenu.menu.children()) { + if (!(item instanceof component)) { + continue + } + item.on(['tap', 'click'], this.submenuClickHandler) + } + } + + // save size of submenus on first init + // if number of submenu items change dynamically more logic will be needed + getSize () { + this.dialog.removeClass('vjs-hidden') + this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) + this.setMargin() + this.dialog.addClass('vjs-hidden') + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + } + + setMargin () { + let [width] = this.size + + this.settingsSubMenuEl_.style.marginRight = `-${width}px` + } + + /** + * Hide the sub menu + */ + hideSubMenu () { + // after removing settings item this.el_ === null + if (!this.el_) { + return + } + + if (videojsUntyped.dom.hasClass(this.el_, 'open')) { + videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + videojsUntyped.dom.removeClass(this.el_, 'open') + } + } + +} + +SettingsMenuItem.prototype.contentElType = 'button' +videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem) + +export { SettingsMenuItem } diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts new file mode 100644 index 000000000..7a99dba1a --- /dev/null +++ b/client/src/assets/player/utils.ts @@ -0,0 +1,72 @@ +function toTitleCase (str: string) { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +// https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts +// Don't import all Angular stuff, just copy the code with shame +const dictionaryBytes: Array<{max: number, type: string}> = [ + { max: 1024, type: 'B' }, + { max: 1048576, type: 'KB' }, + { max: 1073741824, type: 'MB' }, + { max: 1.0995116e12, type: 'GB' } +] +function bytes (value) { + const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] + const calc = Math.floor(value / (format.max / 1024)).toString() + + return [ calc, format.type ] +} + +function getStoredVolume () { + const value = getLocalStorage('volume') + if (value !== null && value !== undefined) { + const valueNumber = parseFloat(value) + if (isNaN(valueNumber)) return undefined + + return valueNumber + } + + return undefined +} + +function getStoredMute () { + const value = getLocalStorage('mute') + if (value !== null && value !== undefined) return value === 'true' + + return undefined +} + +function saveVolumeInStore (value: number) { + return setLocalStorage('volume', value.toString()) +} + +function saveMuteInStore (value: boolean) { + return setLocalStorage('mute', value.toString()) +} + +export { + toTitleCase, + getStoredVolume, + saveVolumeInStore, + saveMuteInStore, + getStoredMute, + bytes +} + +// --------------------------------------------------------------------------- + +const KEY_PREFIX = 'peertube-videojs-' + +function getLocalStorage (key: string) { + try { + return localStorage.getItem(KEY_PREFIX + key) + } catch { + return undefined + } +} + +function setLocalStorage (key: string, value: string) { + try { + localStorage.setItem(KEY_PREFIX + key, value) + } catch { /* empty */ } +} diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts new file mode 100644 index 000000000..8a79e0e50 --- /dev/null +++ b/client/src/assets/player/webtorrent-info-button.ts @@ -0,0 +1,101 @@ +import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' +import { bytes } from './utils' + +const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') +class WebtorrentInfoButton extends Button { + createEl () { + const div = videojsUntyped.dom.createEl('div', { + className: 'vjs-peertube' + }) + const subDivWebtorrent = videojsUntyped.dom.createEl('div', { + className: 'vjs-peertube-hidden' // Hide the stats before we get the info + }) + div.appendChild(subDivWebtorrent) + + const downloadIcon = videojsUntyped.dom.createEl('span', { + className: 'icon icon-download' + }) + subDivWebtorrent.appendChild(downloadIcon) + + const downloadSpeedText = videojsUntyped.dom.createEl('span', { + className: 'download-speed-text' + }) + const downloadSpeedNumber = videojsUntyped.dom.createEl('span', { + className: 'download-speed-number' + }) + const downloadSpeedUnit = videojsUntyped.dom.createEl('span') + downloadSpeedText.appendChild(downloadSpeedNumber) + downloadSpeedText.appendChild(downloadSpeedUnit) + subDivWebtorrent.appendChild(downloadSpeedText) + + const uploadIcon = videojsUntyped.dom.createEl('span', { + className: 'icon icon-upload' + }) + subDivWebtorrent.appendChild(uploadIcon) + + const uploadSpeedText = videojsUntyped.dom.createEl('span', { + className: 'upload-speed-text' + }) + const uploadSpeedNumber = videojsUntyped.dom.createEl('span', { + className: 'upload-speed-number' + }) + const uploadSpeedUnit = videojsUntyped.dom.createEl('span') + uploadSpeedText.appendChild(uploadSpeedNumber) + uploadSpeedText.appendChild(uploadSpeedUnit) + subDivWebtorrent.appendChild(uploadSpeedText) + + const peersText = videojsUntyped.dom.createEl('span', { + className: 'peers-text' + }) + const peersNumber = videojsUntyped.dom.createEl('span', { + className: 'peers-number' + }) + subDivWebtorrent.appendChild(peersNumber) + subDivWebtorrent.appendChild(peersText) + + const subDivHttp = videojsUntyped.dom.createEl('div', { + className: 'vjs-peertube-hidden' + }) + const subDivHttpText = videojsUntyped.dom.createEl('span', { + className: 'peers-number', + textContent: 'HTTP' + }) + const subDivFallbackText = videojsUntyped.dom.createEl('span', { + className: 'peers-text', + textContent: 'fallback' + }) + + subDivHttp.appendChild(subDivHttpText) + subDivHttp.appendChild(subDivFallbackText) + div.appendChild(subDivHttp) + + this.player_.peertube().on('torrentInfo', (event, data) => { + // We are in HTTP fallback + if (!data) { + subDivHttp.className = 'vjs-peertube-displayed' + subDivWebtorrent.className = 'vjs-peertube-hidden' + + return + } + + const downloadSpeed = bytes(data.downloadSpeed) + const uploadSpeed = bytes(data.uploadSpeed) + const numPeers = data.numPeers + + downloadSpeedNumber.textContent = downloadSpeed[ 0 ] + downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] + + uploadSpeedNumber.textContent = uploadSpeed[ 0 ] + uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] + + peersNumber.textContent = numPeers + peersText.textContent = ' peers' + + subDivHttp.className = 'vjs-peertube-hidden' + subDivWebtorrent.className = 'vjs-peertube-displayed' + }) + + return div + } +} +Button.registerComponent('WebTorrentButton', WebtorrentInfoButton) -- cgit v1.2.3