From 57d6503286b114fee61b5e4725825e2490dcac29 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 14 Mar 2022 14:28:20 +0100 Subject: Reorganize player files --- client/src/assets/player/bezels/bezels-plugin.ts | 21 - client/src/assets/player/bezels/pause-bezel.ts | 76 --- .../control-bar/next-previous-video-button.ts | 50 -- .../assets/player/control-bar/p2p-info-button.ts | 124 ---- .../player/control-bar/peertube-link-button.ts | 45 -- .../control-bar/peertube-load-progress-bar.ts | 33 -- .../assets/player/control-bar/theater-button.ts | 53 -- .../assets/player/dock/peertube-dock-component.ts | 65 --- .../src/assets/player/dock/peertube-dock-plugin.ts | 25 - .../player/hotkeys/peertube-hotkeys-plugin.ts | 196 ------- client/src/assets/player/index.ts | 2 +- .../manager-options/control-bar-options-builder.ts | 132 ----- .../player/manager-options/hls-options-builder.ts | 192 ------ .../manager-options/manager-options-builder.ts | 168 ------ .../manager-options/manager-options.model.ts | 84 --- .../manager-options/webtorrent-options-builder.ts | 36 -- .../player/mobile/peertube-mobile-buttons.ts | 94 --- .../assets/player/mobile/peertube-mobile-plugin.ts | 155 ----- .../assets/player/p2p-media-loader/hls-plugin.ts | 419 -------------- .../p2p-media-loader/p2p-media-loader-plugin.ts | 183 ------ .../p2p-media-loader/redundancy-url-manager.ts | 42 -- .../player/p2p-media-loader/segment-url-builder.ts | 17 - .../player/p2p-media-loader/segment-validator.ts | 106 ---- .../src/assets/player/peertube-player-manager.ts | 59 +- client/src/assets/player/peertube-plugin.ts | 302 ---------- .../assets/player/peertube-resolutions-plugin.ts | 88 --- .../src/assets/player/peertube-videojs-typings.ts | 242 -------- .../src/assets/player/playlist/playlist-button.ts | 61 -- .../assets/player/playlist/playlist-menu-item.ts | 136 ----- client/src/assets/player/playlist/playlist-menu.ts | 137 ----- .../src/assets/player/playlist/playlist-plugin.ts | 35 -- .../player/settings/resolution-menu-button.ts | 86 --- .../assets/player/settings/resolution-menu-item.ts | 77 --- .../src/assets/player/settings/settings-dialog.ts | 35 -- .../assets/player/settings/settings-menu-button.ts | 279 --------- .../assets/player/settings/settings-menu-item.ts | 378 ------------ .../assets/player/settings/settings-panel-child.ts | 18 - .../src/assets/player/settings/settings-panel.ts | 18 - .../assets/player/shared/bezels/bezels-plugin.ts | 21 + client/src/assets/player/shared/bezels/index.ts | 2 + .../src/assets/player/shared/bezels/pause-bezel.ts | 76 +++ client/src/assets/player/shared/common/index.ts | 1 + client/src/assets/player/shared/common/utils.ts | 66 +++ .../src/assets/player/shared/control-bar/index.ts | 5 + .../control-bar/next-previous-video-button.ts | 50 ++ .../player/shared/control-bar/p2p-info-button.ts | 124 ++++ .../shared/control-bar/peertube-link-button.ts | 45 ++ .../control-bar/peertube-load-progress-bar.ts | 33 ++ .../player/shared/control-bar/theater-button.ts | 53 ++ client/src/assets/player/shared/dock/index.ts | 2 + .../player/shared/dock/peertube-dock-component.ts | 65 +++ .../player/shared/dock/peertube-dock-plugin.ts | 25 + client/src/assets/player/shared/hotkeys/index.ts | 1 + .../shared/hotkeys/peertube-hotkeys-plugin.ts | 196 +++++++ .../manager-options/control-bar-options-builder.ts | 137 +++++ .../shared/manager-options/hls-options-builder.ts | 192 ++++++ .../assets/player/shared/manager-options/index.ts | 1 + .../manager-options/manager-options-builder.ts | 169 ++++++ .../manager-options/webtorrent-options-builder.ts | 36 ++ client/src/assets/player/shared/mobile/index.ts | 2 + .../shared/mobile/peertube-mobile-buttons.ts | 94 +++ .../player/shared/mobile/peertube-mobile-plugin.ts | 155 +++++ .../player/shared/p2p-media-loader/hls-plugin.ts | 419 ++++++++++++++ .../assets/player/shared/p2p-media-loader/index.ts | 5 + .../p2p-media-loader/p2p-media-loader-plugin.ts | 183 ++++++ .../p2p-media-loader/redundancy-url-manager.ts | 42 ++ .../shared/p2p-media-loader/segment-url-builder.ts | 17 + .../shared/p2p-media-loader/segment-validator.ts | 106 ++++ client/src/assets/player/shared/peertube/index.ts | 1 + .../player/shared/peertube/peertube-plugin.ts | 302 ++++++++++ client/src/assets/player/shared/playlist/index.ts | 4 + .../player/shared/playlist/playlist-button.ts | 61 ++ .../player/shared/playlist/playlist-menu-item.ts | 136 +++++ .../assets/player/shared/playlist/playlist-menu.ts | 137 +++++ .../player/shared/playlist/playlist-plugin.ts | 35 ++ .../src/assets/player/shared/resolutions/index.ts | 1 + .../resolutions/peertube-resolutions-plugin.ts | 88 +++ client/src/assets/player/shared/settings/index.ts | 7 + .../shared/settings/resolution-menu-button.ts | 86 +++ .../player/shared/settings/resolution-menu-item.ts | 77 +++ .../player/shared/settings/settings-dialog.ts | 35 ++ .../player/shared/settings/settings-menu-button.ts | 277 +++++++++ .../player/shared/settings/settings-menu-item.ts | 377 ++++++++++++ .../player/shared/settings/settings-panel-child.ts | 18 + .../player/shared/settings/settings-panel.ts | 18 + client/src/assets/player/shared/stats/index.ts | 2 + .../src/assets/player/shared/stats/stats-card.ts | 271 +++++++++ .../src/assets/player/shared/stats/stats-plugin.ts | 31 + client/src/assets/player/shared/upnext/end-card.ts | 157 +++++ client/src/assets/player/shared/upnext/index.ts | 2 + .../assets/player/shared/upnext/upnext-plugin.ts | 31 + .../shared/webtorrent/peertube-chunk-store.ts | 233 ++++++++ .../player/shared/webtorrent/video-renderer.ts | 133 +++++ .../player/shared/webtorrent/webtorrent-plugin.ts | 641 +++++++++++++++++++++ client/src/assets/player/stats/stats-card.ts | 271 --------- client/src/assets/player/stats/stats-plugin.ts | 31 - client/src/assets/player/types/index.ts | 2 + client/src/assets/player/types/manager-options.ts | 84 +++ .../player/types/peertube-videojs-typings.ts | 242 ++++++++ client/src/assets/player/upnext/end-card.ts | 157 ----- client/src/assets/player/upnext/upnext-plugin.ts | 31 - .../player/webtorrent/peertube-chunk-store.ts | 233 -------- .../src/assets/player/webtorrent/video-renderer.ts | 133 ----- .../assets/player/webtorrent/webtorrent-plugin.ts | 640 -------------------- 104 files changed, 5842 insertions(+), 5735 deletions(-) delete mode 100644 client/src/assets/player/bezels/bezels-plugin.ts delete mode 100644 client/src/assets/player/bezels/pause-bezel.ts delete mode 100644 client/src/assets/player/control-bar/next-previous-video-button.ts delete mode 100644 client/src/assets/player/control-bar/p2p-info-button.ts delete mode 100644 client/src/assets/player/control-bar/peertube-link-button.ts delete mode 100644 client/src/assets/player/control-bar/peertube-load-progress-bar.ts delete mode 100644 client/src/assets/player/control-bar/theater-button.ts delete mode 100644 client/src/assets/player/dock/peertube-dock-component.ts delete mode 100644 client/src/assets/player/dock/peertube-dock-plugin.ts delete mode 100644 client/src/assets/player/hotkeys/peertube-hotkeys-plugin.ts delete mode 100644 client/src/assets/player/manager-options/control-bar-options-builder.ts delete mode 100644 client/src/assets/player/manager-options/hls-options-builder.ts delete mode 100644 client/src/assets/player/manager-options/manager-options-builder.ts delete mode 100644 client/src/assets/player/manager-options/manager-options.model.ts delete mode 100644 client/src/assets/player/manager-options/webtorrent-options-builder.ts delete mode 100644 client/src/assets/player/mobile/peertube-mobile-buttons.ts delete mode 100644 client/src/assets/player/mobile/peertube-mobile-plugin.ts delete mode 100644 client/src/assets/player/p2p-media-loader/hls-plugin.ts delete mode 100644 client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts delete mode 100644 client/src/assets/player/p2p-media-loader/redundancy-url-manager.ts delete mode 100644 client/src/assets/player/p2p-media-loader/segment-url-builder.ts delete mode 100644 client/src/assets/player/p2p-media-loader/segment-validator.ts delete mode 100644 client/src/assets/player/peertube-plugin.ts delete mode 100644 client/src/assets/player/peertube-resolutions-plugin.ts delete mode 100644 client/src/assets/player/peertube-videojs-typings.ts delete mode 100644 client/src/assets/player/playlist/playlist-button.ts delete mode 100644 client/src/assets/player/playlist/playlist-menu-item.ts delete mode 100644 client/src/assets/player/playlist/playlist-menu.ts delete mode 100644 client/src/assets/player/playlist/playlist-plugin.ts delete mode 100644 client/src/assets/player/settings/resolution-menu-button.ts delete mode 100644 client/src/assets/player/settings/resolution-menu-item.ts delete mode 100644 client/src/assets/player/settings/settings-dialog.ts delete mode 100644 client/src/assets/player/settings/settings-menu-button.ts delete mode 100644 client/src/assets/player/settings/settings-menu-item.ts delete mode 100644 client/src/assets/player/settings/settings-panel-child.ts delete mode 100644 client/src/assets/player/settings/settings-panel.ts create mode 100644 client/src/assets/player/shared/bezels/bezels-plugin.ts create mode 100644 client/src/assets/player/shared/bezels/index.ts create mode 100644 client/src/assets/player/shared/bezels/pause-bezel.ts create mode 100644 client/src/assets/player/shared/common/index.ts create mode 100644 client/src/assets/player/shared/common/utils.ts create mode 100644 client/src/assets/player/shared/control-bar/index.ts create mode 100644 client/src/assets/player/shared/control-bar/next-previous-video-button.ts create mode 100644 client/src/assets/player/shared/control-bar/p2p-info-button.ts create mode 100644 client/src/assets/player/shared/control-bar/peertube-link-button.ts create mode 100644 client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts create mode 100644 client/src/assets/player/shared/control-bar/theater-button.ts create mode 100644 client/src/assets/player/shared/dock/index.ts create mode 100644 client/src/assets/player/shared/dock/peertube-dock-component.ts create mode 100644 client/src/assets/player/shared/dock/peertube-dock-plugin.ts create mode 100644 client/src/assets/player/shared/hotkeys/index.ts create mode 100644 client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts create mode 100644 client/src/assets/player/shared/manager-options/control-bar-options-builder.ts create mode 100644 client/src/assets/player/shared/manager-options/hls-options-builder.ts create mode 100644 client/src/assets/player/shared/manager-options/index.ts create mode 100644 client/src/assets/player/shared/manager-options/manager-options-builder.ts create mode 100644 client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts create mode 100644 client/src/assets/player/shared/mobile/index.ts create mode 100644 client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts create mode 100644 client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts create mode 100644 client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts create mode 100644 client/src/assets/player/shared/p2p-media-loader/index.ts create mode 100644 client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts create mode 100644 client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts create mode 100644 client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts create mode 100644 client/src/assets/player/shared/p2p-media-loader/segment-validator.ts create mode 100644 client/src/assets/player/shared/peertube/index.ts create mode 100644 client/src/assets/player/shared/peertube/peertube-plugin.ts create mode 100644 client/src/assets/player/shared/playlist/index.ts create mode 100644 client/src/assets/player/shared/playlist/playlist-button.ts create mode 100644 client/src/assets/player/shared/playlist/playlist-menu-item.ts create mode 100644 client/src/assets/player/shared/playlist/playlist-menu.ts create mode 100644 client/src/assets/player/shared/playlist/playlist-plugin.ts create mode 100644 client/src/assets/player/shared/resolutions/index.ts create mode 100644 client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts create mode 100644 client/src/assets/player/shared/settings/index.ts create mode 100644 client/src/assets/player/shared/settings/resolution-menu-button.ts create mode 100644 client/src/assets/player/shared/settings/resolution-menu-item.ts create mode 100644 client/src/assets/player/shared/settings/settings-dialog.ts create mode 100644 client/src/assets/player/shared/settings/settings-menu-button.ts create mode 100644 client/src/assets/player/shared/settings/settings-menu-item.ts create mode 100644 client/src/assets/player/shared/settings/settings-panel-child.ts create mode 100644 client/src/assets/player/shared/settings/settings-panel.ts create mode 100644 client/src/assets/player/shared/stats/index.ts create mode 100644 client/src/assets/player/shared/stats/stats-card.ts create mode 100644 client/src/assets/player/shared/stats/stats-plugin.ts create mode 100644 client/src/assets/player/shared/upnext/end-card.ts create mode 100644 client/src/assets/player/shared/upnext/index.ts create mode 100644 client/src/assets/player/shared/upnext/upnext-plugin.ts create mode 100644 client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts create mode 100644 client/src/assets/player/shared/webtorrent/video-renderer.ts create mode 100644 client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts delete mode 100644 client/src/assets/player/stats/stats-card.ts delete mode 100644 client/src/assets/player/stats/stats-plugin.ts create mode 100644 client/src/assets/player/types/index.ts create mode 100644 client/src/assets/player/types/manager-options.ts create mode 100644 client/src/assets/player/types/peertube-videojs-typings.ts delete mode 100644 client/src/assets/player/upnext/end-card.ts delete mode 100644 client/src/assets/player/upnext/upnext-plugin.ts delete mode 100644 client/src/assets/player/webtorrent/peertube-chunk-store.ts delete mode 100644 client/src/assets/player/webtorrent/video-renderer.ts delete mode 100644 client/src/assets/player/webtorrent/webtorrent-plugin.ts (limited to 'client/src/assets/player') diff --git a/client/src/assets/player/bezels/bezels-plugin.ts b/client/src/assets/player/bezels/bezels-plugin.ts deleted file mode 100644 index ca88bc1f9..000000000 --- a/client/src/assets/player/bezels/bezels-plugin.ts +++ /dev/null @@ -1,21 +0,0 @@ -import videojs from 'video.js' -import './pause-bezel' - -const Plugin = videojs.getPlugin('plugin') - -class BezelsPlugin extends Plugin { - - constructor (player: videojs.Player, options?: videojs.ComponentOptions) { - super(player) - - this.player.ready(() => { - player.addClass('vjs-bezels') - }) - - player.addChild('PauseBezel', options) - } -} - -videojs.registerPlugin('bezels', BezelsPlugin) - -export { BezelsPlugin } diff --git a/client/src/assets/player/bezels/pause-bezel.ts b/client/src/assets/player/bezels/pause-bezel.ts deleted file mode 100644 index 315964311..000000000 --- a/client/src/assets/player/bezels/pause-bezel.ts +++ /dev/null @@ -1,76 +0,0 @@ -import videojs from 'video.js' -import { isMobile } from '../utils' - -function getPauseBezel () { - return ` -
-
-
- - - - -
-
-
- ` -} - -function getPlayBezel () { - return ` -
-
-
- - - - -
-
-
- ` -} - -const Component = videojs.getComponent('Component') -class PauseBezel extends Component { - container: HTMLDivElement - - constructor (player: videojs.Player, options?: videojs.ComponentOptions) { - super(player, options) - - // Hide bezels on mobile since we already have our mobile overlay - if (isMobile()) return - - player.on('pause', (_: any) => { - if (player.seeking() || player.ended()) return - this.container.innerHTML = getPauseBezel() - this.showBezel() - }) - - player.on('play', (_: any) => { - if (player.seeking()) return - this.container.innerHTML = getPlayBezel() - this.showBezel() - }) - } - - createEl () { - this.container = super.createEl('div', { - className: 'vjs-bezels-content' - }) as HTMLDivElement - - this.container.style.display = 'none' - - return this.container - } - - showBezel () { - this.container.style.display = 'inherit' - - setTimeout(() => { - this.container.style.display = 'none' - }, 500) // matching the animation duration - } -} - -videojs.registerComponent('PauseBezel', PauseBezel) diff --git a/client/src/assets/player/control-bar/next-previous-video-button.ts b/client/src/assets/player/control-bar/next-previous-video-button.ts deleted file mode 100644 index fe17ce2ce..000000000 --- a/client/src/assets/player/control-bar/next-previous-video-button.ts +++ /dev/null @@ -1,50 +0,0 @@ -import videojs from 'video.js' -import { NextPreviousVideoButtonOptions } from '../peertube-videojs-typings' - -const Button = videojs.getComponent('Button') - -class NextPreviousVideoButton extends Button { - private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions - - constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { - super(player, options as any) - - this.nextPreviousVideoButtonOptions = options - - this.update() - } - - createEl () { - const type = (this.options_ as NextPreviousVideoButtonOptions).type - - const button = videojs.dom.createEl('button', { - className: 'vjs-' + type + '-video' - }) as HTMLButtonElement - const nextIcon = videojs.dom.createEl('span', { - className: 'icon icon-' + type - }) - button.appendChild(nextIcon) - - if (type === 'next') { - button.title = this.player_.localize('Next video') - } else { - button.title = this.player_.localize('Previous video') - } - - return button - } - - handleClick () { - this.nextPreviousVideoButtonOptions.handler() - } - - update () { - const disabled = this.nextPreviousVideoButtonOptions.isDisabled() - - if (disabled) this.addClass('vjs-disabled') - else this.removeClass('vjs-disabled') - } -} - -videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) -videojs.registerComponent('PreviousVideoButton', NextPreviousVideoButton) diff --git a/client/src/assets/player/control-bar/p2p-info-button.ts b/client/src/assets/player/control-bar/p2p-info-button.ts deleted file mode 100644 index 081dee1d3..000000000 --- a/client/src/assets/player/control-bar/p2p-info-button.ts +++ /dev/null @@ -1,124 +0,0 @@ -import videojs from 'video.js' -import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../peertube-videojs-typings' -import { bytes } from '../utils' - -const Button = videojs.getComponent('Button') -class P2pInfoButton extends Button { - - constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) { - super(player, options as any) - } - - createEl () { - const div = videojs.dom.createEl('div', { - className: 'vjs-peertube' - }) - const subDivWebtorrent = videojs.dom.createEl('div', { - className: 'vjs-peertube-hidden' // Hide the stats before we get the info - }) as HTMLDivElement - div.appendChild(subDivWebtorrent) - - // Stop here if P2P is not enabled - const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled - if (!p2pEnabled) return div as HTMLButtonElement - - const downloadIcon = videojs.dom.createEl('span', { - className: 'icon icon-download' - }) - subDivWebtorrent.appendChild(downloadIcon) - - const downloadSpeedText = videojs.dom.createEl('span', { - className: 'download-speed-text' - }) - const downloadSpeedNumber = videojs.dom.createEl('span', { - className: 'download-speed-number' - }) - const downloadSpeedUnit = videojs.dom.createEl('span') - downloadSpeedText.appendChild(downloadSpeedNumber) - downloadSpeedText.appendChild(downloadSpeedUnit) - subDivWebtorrent.appendChild(downloadSpeedText) - - const uploadIcon = videojs.dom.createEl('span', { - className: 'icon icon-upload' - }) - subDivWebtorrent.appendChild(uploadIcon) - - const uploadSpeedText = videojs.dom.createEl('span', { - className: 'upload-speed-text' - }) - const uploadSpeedNumber = videojs.dom.createEl('span', { - className: 'upload-speed-number' - }) - const uploadSpeedUnit = videojs.dom.createEl('span') - uploadSpeedText.appendChild(uploadSpeedNumber) - uploadSpeedText.appendChild(uploadSpeedUnit) - subDivWebtorrent.appendChild(uploadSpeedText) - - const peersText = videojs.dom.createEl('span', { - className: 'peers-text' - }) - const peersNumber = videojs.dom.createEl('span', { - className: 'peers-number' - }) - subDivWebtorrent.appendChild(peersNumber) - subDivWebtorrent.appendChild(peersText) - - const subDivHttp = videojs.dom.createEl('div', { - className: 'vjs-peertube-hidden' - }) - const subDivHttpText = videojs.dom.createEl('span', { - className: 'http-fallback', - textContent: 'HTTP' - }) - - subDivHttp.appendChild(subDivHttpText) - div.appendChild(subDivHttp) - - this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { - // We are in HTTP fallback - if (!data) { - subDivHttp.className = 'vjs-peertube-displayed' - subDivWebtorrent.className = 'vjs-peertube-hidden' - - return - } - - const p2pStats = data.p2p - const httpStats = data.http - - const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed) - const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed) - const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded) - const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded) - const numPeers = p2pStats.numPeers - - subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' - - if (data.source === 'p2p-media-loader') { - const downloadedFromServer = bytes(httpStats.downloaded).join(' ') - const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') - - subDivWebtorrent.title += - ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + - ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' - } - subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') - - downloadSpeedNumber.textContent = downloadSpeed[0] - downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] - - uploadSpeedNumber.textContent = uploadSpeed[0] - uploadSpeedUnit.textContent = ' ' + uploadSpeed[1] - - peersNumber.textContent = numPeers.toString() - peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) - - subDivHttp.className = 'vjs-peertube-hidden' - subDivWebtorrent.className = 'vjs-peertube-displayed' - }) - - return div as HTMLButtonElement - } -} - -videojs.registerComponent('P2PInfoButton', P2pInfoButton) diff --git a/client/src/assets/player/control-bar/peertube-link-button.ts b/client/src/assets/player/control-bar/peertube-link-button.ts deleted file mode 100644 index c49cee566..000000000 --- a/client/src/assets/player/control-bar/peertube-link-button.ts +++ /dev/null @@ -1,45 +0,0 @@ -import videojs from 'video.js' -import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' -import { PeerTubeLinkButtonOptions } from '../peertube-videojs-typings' - -const Button = videojs.getComponent('Button') -class PeerTubeLinkButton extends Button { - - constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { - super(player, options as any) - } - - createEl () { - return this.buildElement() - } - - updateHref () { - this.el().setAttribute('href', this.buildLink()) - } - - handleClick () { - this.player().pause() - } - - private buildElement () { - const el = videojs.dom.createEl('a', { - href: this.buildLink(), - innerHTML: 'PeerTube', - title: this.player().localize('Video page (new window)'), - className: 'vjs-peertube-link', - target: '_blank' - }) - - el.addEventListener('mouseenter', () => this.updateHref()) - - return el as HTMLButtonElement - } - - private buildLink () { - const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID }) - - return decorateVideoLink({ url, startTime: this.player().currentTime() }) - } -} - -videojs.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) diff --git a/client/src/assets/player/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/control-bar/peertube-load-progress-bar.ts deleted file mode 100644 index 623e70eb2..000000000 --- a/client/src/assets/player/control-bar/peertube-load-progress-bar.ts +++ /dev/null @@ -1,33 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') - -class PeerTubeLoadProgressBar extends Component { - - constructor (player: videojs.Player, options?: videojs.ComponentOptions) { - super(player, options) - - this.on(player, 'progress', this.update) - } - - createEl () { - return super.createEl('div', { - className: 'vjs-load-progress', - innerHTML: `${this.localize('Loaded')}: 0%` - }) - } - - dispose () { - super.dispose() - } - - update () { - const torrent = this.player().webtorrent().getTorrent() - if (!torrent) return - - (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%' - } - -} - -Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) diff --git a/client/src/assets/player/control-bar/theater-button.ts b/client/src/assets/player/control-bar/theater-button.ts deleted file mode 100644 index f862ee224..000000000 --- a/client/src/assets/player/control-bar/theater-button.ts +++ /dev/null @@ -1,53 +0,0 @@ -import videojs from 'video.js' -import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage' - -const Button = videojs.getComponent('Button') -class TheaterButton extends Button { - - private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' - - constructor (player: videojs.Player, options: videojs.ComponentOptions) { - super(player, options) - - const enabled = getStoredTheater() - if (enabled === true) { - this.player().addClass(TheaterButton.THEATER_MODE_CLASS) - - this.handleTheaterChange() - } - - this.controlText('Theater mode') - - this.player().theaterEnabled = enabled - } - - buildCSSClass () { - return `vjs-theater-control ${super.buildCSSClass()}` - } - - handleTheaterChange () { - const theaterEnabled = this.isTheaterEnabled() - - if (theaterEnabled) { - this.controlText('Normal mode') - } else { - this.controlText('Theater mode') - } - - saveTheaterInStore(theaterEnabled) - - this.player_.trigger('theaterChange', theaterEnabled) - } - - handleClick () { - this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS) - - this.handleTheaterChange() - } - - private isTheaterEnabled () { - return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) - } -} - -videojs.registerComponent('TheaterButton', TheaterButton) diff --git a/client/src/assets/player/dock/peertube-dock-component.ts b/client/src/assets/player/dock/peertube-dock-component.ts deleted file mode 100644 index 183c7a00f..000000000 --- a/client/src/assets/player/dock/peertube-dock-component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') - -export type PeerTubeDockComponentOptions = { - title?: string - description?: string - avatarUrl?: string -} - -class PeerTubeDockComponent extends Component { - - createEl () { - const options = this.options_ as PeerTubeDockComponentOptions - - const el = super.createEl('div', { - className: 'peertube-dock' - }) - - if (options.avatarUrl) { - const avatar = videojs.dom.createEl('img', { - className: 'peertube-dock-avatar', - src: options.avatarUrl - }) - - el.appendChild(avatar) - } - - const elWrapperTitleDescription = super.createEl('div', { - className: 'peertube-dock-title-description' - }) - - if (options.title) { - const title = videojs.dom.createEl('div', { - className: 'peertube-dock-title', - title: options.title, - innerHTML: options.title - }) - - elWrapperTitleDescription.appendChild(title) - } - - if (options.description) { - const description = videojs.dom.createEl('div', { - className: 'peertube-dock-description', - title: options.description, - innerHTML: options.description - }) - - elWrapperTitleDescription.appendChild(description) - } - - if (options.title || options.description) { - el.appendChild(elWrapperTitleDescription) - } - - return el - } -} - -videojs.registerComponent('PeerTubeDockComponent', PeerTubeDockComponent) - -export { - PeerTubeDockComponent -} diff --git a/client/src/assets/player/dock/peertube-dock-plugin.ts b/client/src/assets/player/dock/peertube-dock-plugin.ts deleted file mode 100644 index 245981692..000000000 --- a/client/src/assets/player/dock/peertube-dock-plugin.ts +++ /dev/null @@ -1,25 +0,0 @@ -import videojs from 'video.js' -import { PeerTubeDockComponent } from './peertube-dock-component' - -const Plugin = videojs.getPlugin('plugin') - -export type PeerTubeDockPluginOptions = { - title?: string - description?: string - avatarUrl?: string -} - -class PeerTubeDockPlugin extends Plugin { - constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { - super(player, options) - - this.player.addClass('peertube-dock') - - this.player.ready(() => { - this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent - }) - } -} - -videojs.registerPlugin('peertubeDock', PeerTubeDockPlugin) -export { PeerTubeDockPlugin } diff --git a/client/src/assets/player/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/hotkeys/peertube-hotkeys-plugin.ts deleted file mode 100644 index 5920450bd..000000000 --- a/client/src/assets/player/hotkeys/peertube-hotkeys-plugin.ts +++ /dev/null @@ -1,196 +0,0 @@ -import videojs from 'video.js' - -type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardEvent) => void } - -const Plugin = videojs.getPlugin('plugin') - -class PeerTubeHotkeysPlugin extends Plugin { - private static readonly VOLUME_STEP = 0.1 - private static readonly SEEK_STEP = 5 - - private readonly handleKeyFunction: (event: KeyboardEvent) => void - - private readonly handlers: KeyHandler[] - - constructor (player: videojs.Player, options: videojs.PlayerOptions) { - super(player, options) - - this.handlers = this.buildHandlers() - - this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event) - document.addEventListener('keydown', this.handleKeyFunction) - } - - dispose () { - document.removeEventListener('keydown', this.handleKeyFunction) - } - - private onKeyDown (event: KeyboardEvent) { - if (!this.isValidKeyTarget(event.target as HTMLElement)) return - - for (const handler of this.handlers) { - if (handler.accept(event)) { - handler.cb(event) - return - } - } - } - - private buildHandlers () { - const handlers: KeyHandler[] = [ - // Play - { - accept: e => (e.key === ' ' || e.key === 'MediaPlayPause'), - cb: e => { - e.preventDefault() - e.stopPropagation() - - if (this.player.paused()) this.player.play() - else this.player.pause() - } - }, - - // Increase volume - { - accept: e => this.isNaked(e, 'ArrowUp'), - cb: e => { - e.preventDefault() - this.player.volume(this.player.volume() + PeerTubeHotkeysPlugin.VOLUME_STEP) - } - }, - - // Decrease volume - { - accept: e => this.isNaked(e, 'ArrowDown'), - cb: e => { - e.preventDefault() - this.player.volume(this.player.volume() - PeerTubeHotkeysPlugin.VOLUME_STEP) - } - }, - - // Rewind - { - accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'), - cb: e => { - e.preventDefault() - - const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP) - this.player.currentTime(target) - } - }, - - // Forward - { - accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'), - cb: e => { - e.preventDefault() - - const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP) - this.player.currentTime(target) - } - }, - - // Fullscreen - { - // f key or Ctrl + Enter - accept: e => this.isNaked(e, 'f') || (!e.altKey && e.ctrlKey && e.key === 'Enter'), - cb: e => { - e.preventDefault() - - if (this.player.isFullscreen()) this.player.exitFullscreen() - else this.player.requestFullscreen() - } - }, - - // Mute - { - accept: e => this.isNaked(e, 'm'), - cb: e => { - e.preventDefault() - - this.player.muted(!this.player.muted()) - } - }, - - // Increase playback rate - { - accept: e => e.key === '>', - cb: () => { - const target = Math.min(this.player.playbackRate() + 0.1, 5) - - this.player.playbackRate(parseFloat(target.toFixed(2))) - } - }, - - // Decrease playback rate - { - accept: e => e.key === '<', - cb: () => { - const target = Math.max(this.player.playbackRate() - 0.1, 0.10) - - this.player.playbackRate(parseFloat(target.toFixed(2))) - } - }, - - // Previous frame - { - accept: e => e.key === ',', - cb: () => { - this.player.pause() - - // Calculate movement distance (assuming 30 fps) - const dist = 1 / 30 - this.player.currentTime(this.player.currentTime() - dist) - } - }, - - // Next frame - { - accept: e => e.key === '.', - cb: () => { - this.player.pause() - - // Calculate movement distance (assuming 30 fps) - const dist = 1 / 30 - this.player.currentTime(this.player.currentTime() + dist) - } - } - ] - - // 0-9 key handlers - for (let i = 0; i < 10; i++) { - handlers.push({ - accept: e => e.key === i + '', - cb: e => { - e.preventDefault() - - this.player.currentTime(this.player.duration() * i * 0.1) - } - }) - } - - return handlers - } - - private isValidKeyTarget (eventEl: HTMLElement) { - const playerEl = this.player.el() - const activeEl = document.activeElement - const currentElTagName = eventEl.tagName.toLowerCase() - - return ( - activeEl === playerEl || - activeEl === playerEl.querySelector('.vjs-tech') || - activeEl === playerEl.querySelector('.vjs-control-bar') || - eventEl.id === 'content' || - currentElTagName === 'body' || - currentElTagName === 'video' - ) - } - - private isNaked (event: KeyboardEvent, key: string) { - return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === key) - } -} - -videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin) -export { PeerTubeHotkeysPlugin } diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts index 92270476d..9b87afc4a 100644 --- a/client/src/assets/player/index.ts +++ b/client/src/assets/player/index.ts @@ -1,2 +1,2 @@ export * from './peertube-player-manager' -export * from './manager-options/manager-options.model' +export * from './types' diff --git a/client/src/assets/player/manager-options/control-bar-options-builder.ts b/client/src/assets/player/manager-options/control-bar-options-builder.ts deleted file mode 100644 index 54e61c5d0..000000000 --- a/client/src/assets/player/manager-options/control-bar-options-builder.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { NextPreviousVideoButtonOptions, PeerTubeLinkButtonOptions } from '../peertube-videojs-typings' -import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from './manager-options.model' - -export class ControlBarOptionsBuilder { - private options: CommonOptions - - constructor ( - globalOptions: PeertubePlayerManagerOptions, - private mode: PlayerMode - ) { - this.options = globalOptions.common - } - - getChildrenOptions () { - const children = {} - - if (this.options.previousVideo) { - Object.assign(children, this.getPreviousVideo()) - } - - Object.assign(children, { playToggle: {} }) - - if (this.options.nextVideo) { - Object.assign(children, this.getNextVideo()) - } - - Object.assign(children, { - currentTimeDisplay: {}, - timeDivider: {}, - durationDisplay: {}, - liveDisplay: {}, - - flexibleWidthSpacer: {}, - - ...this.getProgressControl(), - - p2PInfoButton: { - p2pEnabled: this.options.p2pEnabled - }, - - muteToggle: {}, - volumeControl: {}, - - settingsButton: this.getSettingsButton() - }) - - if (this.options.peertubeLink === true) { - Object.assign(children, { - peerTubeLinkButton: { shortUUID: this.options.videoShortUUID } as PeerTubeLinkButtonOptions - }) - } - - if (this.options.theaterButton === true) { - Object.assign(children, { - theaterButton: {} - }) - } - - Object.assign(children, { - fullscreenToggle: {} - }) - - return children - } - - private getSettingsButton () { - const settingEntries: string[] = [] - - settingEntries.push('playbackRateMenuButton') - - if (this.options.captions === true) settingEntries.push('captionsButton') - - settingEntries.push('resolutionMenuButton') - - return { - settingsButton: { - setup: { - maxHeightOffset: 40 - }, - entries: settingEntries - } - } - } - - private getProgressControl () { - const loadProgressBar = this.mode === 'webtorrent' - ? 'peerTubeLoadProgressBar' - : 'loadProgressBar' - - return { - progressControl: { - children: { - seekBar: { - children: { - [loadProgressBar]: {}, - mouseTimeDisplay: {}, - playProgressBar: {} - } - } - } - } - } - } - - private getPreviousVideo () { - const buttonOptions: NextPreviousVideoButtonOptions = { - type: 'previous', - handler: this.options.previousVideo, - isDisabled: () => { - if (!this.options.hasPreviousVideo) return false - - return !this.options.hasPreviousVideo() - } - } - - return { previousVideoButton: buttonOptions } - } - - private getNextVideo () { - const buttonOptions: NextPreviousVideoButtonOptions = { - type: 'next', - handler: this.options.nextVideo, - isDisabled: () => { - if (!this.options.hasNextVideo) return false - - return !this.options.hasNextVideo() - } - } - - return { nextVideoButton: buttonOptions } - } -} diff --git a/client/src/assets/player/manager-options/hls-options-builder.ts b/client/src/assets/player/manager-options/hls-options-builder.ts deleted file mode 100644 index 9de23561b..000000000 --- a/client/src/assets/player/manager-options/hls-options-builder.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core' -import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' -import { LiveVideoLatencyMode } 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 { getAverageBandwidthInStore } from '../peertube-player-local-storage' -import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../peertube-videojs-typings' -import { getRtcConfig } from '../utils' -import { PeertubePlayerManagerOptions } from './manager-options.model' - -export class HLSOptionsBuilder { - - constructor ( - private options: PeertubePlayerManagerOptions, - private p2pMediaLoaderModule?: any - ) { - - } - - getPluginOptions () { - const commonOptions = this.options.common - - const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) - - const p2pMediaLoaderConfig = this.getP2PMediaLoaderOptions(redundancyUrlManager) - const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader - - const p2pMediaLoader: P2PMediaLoaderPluginOptions = { - redundancyUrlManager, - type: 'application/x-mpegURL', - startTime: commonOptions.startTime, - src: this.options.p2pMediaLoader.playlistUrl, - loader - } - - const hlsjs = { - levelLabelHandler: (level: { height: number, width: number }) => { - const resolution = Math.min(level.height || 0, level.width || 0) - - const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution) - // We don't have files for live videos - if (!file) return level.height - - let label = file.resolution.label - if (file.fps >= 50) label += file.fps - - return label - }, - html5: { - hlsjsConfig: this.getHLSJSOptions(loader) - } - } - - return { p2pMediaLoader, hlsjs } - } - - // --------------------------------------------------------------------------- - - private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { - let consumeOnly = false - if ((navigator as any)?.connection?.type === 'cellular') { - console.log('We are on a cellular connection: disabling seeding.') - consumeOnly = true - } - - const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce - .filter(t => t.startsWith('ws')) - - const specificLiveOrVODOptions = this.options.common.isLive - ? this.getP2PMediaLoaderLiveOptions() - : this.getP2PMediaLoaderVODOptions() - - return { - loader: { - - trackerAnnounce, - rtcConfig: getRtcConfig(), - - simultaneousHttpDownloads: 1, - httpFailedSegmentTimeout: 1000, - - segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive), - segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1), - - useP2P: this.options.common.p2pEnabled, - consumeOnly, - - ...specificLiveOrVODOptions - }, - segments: { - swarmId: this.options.p2pMediaLoader.playlistUrl, - forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority - } - } - } - - private getP2PMediaLoaderLiveOptions (): Partial { - const base = { - requiredSegmentsPriority: 1 - } - - const latencyMode = this.options.common.liveOptions.latencyMode - - switch (latencyMode) { - case LiveVideoLatencyMode.SMALL_LATENCY: - return { - ...base, - - useP2P: false, - httpDownloadProbability: 1 - } - - case LiveVideoLatencyMode.HIGH_LATENCY: - return base - - default: - return base - } - } - - private getP2PMediaLoaderVODOptions (): Partial { - return { - requiredSegmentsPriority: 3, - - cachedSegmentExpiration: 86400000, - cachedSegmentsCount: 100, - - httpDownloadMaxPriority: 9, - httpDownloadProbability: 0.06, - httpDownloadProbabilitySkipIfNoPeers: true, - - p2pDownloadMaxPriority: 50 - } - } - - // --------------------------------------------------------------------------- - - private getHLSJSOptions (loader: P2PMediaLoader) { - const specificLiveOrVODOptions = this.options.common.isLive - ? this.getHLSLiveOptions() - : this.getHLSVODOptions() - - const base = { - capLevelToPlayerSize: true, - autoStartLoad: false, - - loader, - - ...specificLiveOrVODOptions - } - - const averageBandwidth = getAverageBandwidthInStore() - if (!averageBandwidth) return base - - return { - ...base, - - abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s - startLevel: -1, - testBandwidth: false, - debug: false - } - } - - private getHLSLiveOptions () { - const latencyMode = this.options.common.liveOptions.latencyMode - - switch (latencyMode) { - case LiveVideoLatencyMode.SMALL_LATENCY: - return { - liveSyncDurationCount: 2 - } - - case LiveVideoLatencyMode.HIGH_LATENCY: - return { - liveSyncDurationCount: 10 - } - - default: - return { - liveSyncDurationCount: 5 - } - } - } - - private getHLSVODOptions () { - return { - liveSyncDurationCount: 5 - } - } -} diff --git a/client/src/assets/player/manager-options/manager-options-builder.ts b/client/src/assets/player/manager-options/manager-options-builder.ts deleted file mode 100644 index 14bdb5d96..000000000 --- a/client/src/assets/player/manager-options/manager-options-builder.ts +++ /dev/null @@ -1,168 +0,0 @@ -import videojs from 'video.js' -import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' -import { isDefaultLocale } from '@shared/core-utils/i18n' -import { copyToClipboard } from '../../../root-helpers/utils' -import { VideoJSPluginOptions } from '../peertube-videojs-typings' -import { buildVideoOrPlaylistEmbed, isIOS, isSafari } from '../utils' -import { ControlBarOptionsBuilder } from './control-bar-options-builder' -import { HLSOptionsBuilder } from './hls-options-builder' -import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from './manager-options.model' -import { WebTorrentOptionsBuilder } from './webtorrent-options-builder' - -export class ManagerOptionsBuilder { - - constructor ( - private mode: PlayerMode, - private options: PeertubePlayerManagerOptions, - private p2pMediaLoaderModule?: any - ) { - - } - - getVideojsOptions (alreadyPlayed: boolean): videojs.PlayerOptions { - const commonOptions = this.options.common - - let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) - const html5 = { - preloadTextTracks: false - } - - const plugins: VideoJSPluginOptions = { - peertube: { - mode: this.mode, - autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent - videoViewUrl: commonOptions.videoViewUrl, - videoDuration: commonOptions.videoDuration, - userWatching: commonOptions.userWatching, - subtitle: commonOptions.subtitle, - videoCaptions: commonOptions.videoCaptions, - stopTime: commonOptions.stopTime, - isLive: commonOptions.isLive, - videoUUID: commonOptions.videoUUID - } - } - - if (commonOptions.playlist) { - plugins.playlist = commonOptions.playlist - } - - if (this.mode === 'p2p-media-loader') { - const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule) - - Object.assign(plugins, hlsOptionsBuilder.getPluginOptions()) - } else if (this.mode === 'webtorrent') { - const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed)) - - Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions()) - - // WebTorrent plugin handles autoplay, because we do some hackish stuff in there - autoplay = false - } - - const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode) - - const videojsOptions = { - html5, - - // We don't use text track settings for now - textTrackSettings: false as any, // FIXME: typings - controls: commonOptions.controls !== undefined ? commonOptions.controls : true, - loop: commonOptions.loop !== undefined ? commonOptions.loop : false, - - muted: commonOptions.muted !== undefined - ? commonOptions.muted - : undefined, // Undefined so the player knows it has to check the local storage - - autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed), - - poster: commonOptions.poster, - inactivityTimeout: commonOptions.inactivityTimeout, - playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], - - plugins, - - controlBar: { - children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings - } - } - - if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { - Object.assign(videojsOptions, { language: commonOptions.language }) - } - - return videojsOptions - } - - private getAutoPlayValue (autoplay: any, alreadyPlayed: boolean) { - if (autoplay !== true) return autoplay - - // On first play, disable autoplay to avoid issues - // But if the player already played videos, we can safely autoplay next ones - if (isIOS() || isSafari()) { - return alreadyPlayed ? 'play' : false - } - - return 'play' - } - - getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) { - const content = () => { - const isLoopEnabled = player.options_['loop'] - - const items = [ - { - icon: 'repeat', - label: player.localize('Play in loop') + (isLoopEnabled ? '' : ''), - listener: function () { - player.options_['loop'] = !isLoopEnabled - } - }, - { - label: player.localize('Copy the video URL'), - listener: function () { - copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID })) - } - }, - { - label: player.localize('Copy the video URL at the current time'), - listener: function (this: videojs.Player) { - const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID }) - - copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) - } - }, - { - icon: 'code', - label: player.localize('Copy embed code'), - listener: () => { - copyToClipboard(buildVideoOrPlaylistEmbed(commonOptions.embedUrl, commonOptions.embedTitle)) - } - } - ] - - if (this.mode === 'webtorrent') { - items.push({ - label: player.localize('Copy magnet URI'), - listener: function (this: videojs.Player) { - copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) - } - }) - } - - items.push({ - icon: 'info', - label: player.localize('Stats for nerds'), - listener: () => { - player.stats().show() - } - }) - - return items.map(i => ({ - ...i, - label: `` + i.label - })) - } - - return { content } - } -} diff --git a/client/src/assets/player/manager-options/manager-options.model.ts b/client/src/assets/player/manager-options/manager-options.model.ts deleted file mode 100644 index 0b0f8b435..000000000 --- a/client/src/assets/player/manager-options/manager-options.model.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { PluginsManager } from '@root-helpers/plugins-manager' -import { LiveVideoLatencyMode, VideoFile } from '@shared/models' -import { PlaylistPluginOptions, UserWatching, VideoJSCaption } from '../peertube-videojs-typings' - -export type PlayerMode = 'webtorrent' | 'p2p-media-loader' - -export type WebtorrentOptions = { - videoFiles: VideoFile[] -} - -export type P2PMediaLoaderOptions = { - playlistUrl: string - segmentsSha256Url: string - trackerAnnounce: string[] - redundancyBaseUrls: string[] - videoFiles: VideoFile[] -} - -export interface CustomizationOptions { - startTime: number | string - stopTime: number | string - - controls?: boolean - muted?: boolean - loop?: boolean - subtitle?: string - resume?: string - - peertubeLink: boolean -} - -export interface CommonOptions extends CustomizationOptions { - playerElement: HTMLVideoElement - onPlayerElementChange: (element: HTMLVideoElement) => void - - autoplay: boolean - p2pEnabled: boolean - - nextVideo?: () => void - hasNextVideo?: () => boolean - - previousVideo?: () => void - hasPreviousVideo?: () => boolean - - playlist?: PlaylistPluginOptions - - videoDuration: number - enableHotkeys: boolean - inactivityTimeout: number - poster: string - - theaterButton: boolean - captions: boolean - - videoViewUrl: string - embedUrl: string - embedTitle: string - - isLive: boolean - liveOptions?: { - latencyMode: LiveVideoLatencyMode - } - - language?: string - - videoCaptions: VideoJSCaption[] - - videoUUID: string - videoShortUUID: string - - userWatching?: UserWatching - - serverUrl: string - - errorNotifier: (message: string) => void -} - -export type PeertubePlayerManagerOptions = { - common: CommonOptions - webtorrent: WebtorrentOptions - p2pMediaLoader?: P2PMediaLoaderOptions - - pluginsManager: PluginsManager -} diff --git a/client/src/assets/player/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/manager-options/webtorrent-options-builder.ts deleted file mode 100644 index 303940b29..000000000 --- a/client/src/assets/player/manager-options/webtorrent-options-builder.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { PeertubePlayerManagerOptions } from './manager-options.model' - -export class WebTorrentOptionsBuilder { - - constructor ( - private options: PeertubePlayerManagerOptions, - private autoPlayValue: any - ) { - - } - - getPluginOptions () { - const commonOptions = this.options.common - const webtorrentOptions = this.options.webtorrent - const p2pMediaLoaderOptions = this.options.p2pMediaLoader - - const autoplay = this.autoPlayValue === 'play' - - const webtorrent = { - autoplay, - - playerRefusedP2P: commonOptions.p2pEnabled === false, - videoDuration: commonOptions.videoDuration, - playerElement: commonOptions.playerElement, - - videoFiles: webtorrentOptions.videoFiles.length !== 0 - ? webtorrentOptions.videoFiles - // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode - : p2pMediaLoaderOptions?.videoFiles || [], - - startTime: commonOptions.startTime - } - - return { webtorrent } - } -} diff --git a/client/src/assets/player/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/mobile/peertube-mobile-buttons.ts deleted file mode 100644 index 09cb98f2e..000000000 --- a/client/src/assets/player/mobile/peertube-mobile-buttons.ts +++ /dev/null @@ -1,94 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') -class PeerTubeMobileButtons extends Component { - - private rewind: Element - private forward: Element - private rewindText: Element - private forwardText: Element - - createEl () { - const container = super.createEl('div', { - className: 'vjs-mobile-buttons-overlay' - }) as HTMLDivElement - - const mainButton = super.createEl('div', { - className: 'main-button' - }) as HTMLDivElement - - mainButton.addEventListener('touchstart', e => { - e.stopPropagation() - - if (this.player_.paused() || this.player_.ended()) { - this.player_.play() - return - } - - this.player_.pause() - }) - - this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) - this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) - - for (let i = 0; i < 3; i++) { - this.rewind.appendChild(super.createEl('span', { className: 'icon' })) - this.forward.appendChild(super.createEl('span', { className: 'icon' })) - } - - this.rewindText = this.rewind.appendChild(super.createEl('div', { className: 'text' })) - this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) - - container.appendChild(this.rewind) - container.appendChild(mainButton) - container.appendChild(this.forward) - - return container - } - - displayFastSeek (amount: number) { - if (amount === 0) { - this.hideRewind() - this.hideForward() - return - } - - if (amount > 0) { - this.hideRewind() - this.displayForward(amount) - return - } - - if (amount < 0) { - this.hideForward() - this.displayRewind(amount) - return - } - } - - private hideRewind () { - this.rewind.classList.add('vjs-hidden') - this.rewindText.textContent = '' - } - - private displayRewind (amount: number) { - this.rewind.classList.remove('vjs-hidden') - this.rewindText.textContent = this.player().localize('{1} seconds', [ amount + '' ]) - } - - private hideForward () { - this.forward.classList.add('vjs-hidden') - this.forwardText.textContent = '' - } - - private displayForward (amount: number) { - this.forward.classList.remove('vjs-hidden') - this.forwardText.textContent = this.player().localize('{1} seconds', [ amount + '' ]) - } -} - -videojs.registerComponent('PeerTubeMobileButtons', PeerTubeMobileButtons) - -export { - PeerTubeMobileButtons -} diff --git a/client/src/assets/player/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/mobile/peertube-mobile-plugin.ts deleted file mode 100644 index 91dda7f94..000000000 --- a/client/src/assets/player/mobile/peertube-mobile-plugin.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { PeerTubeMobileButtons } from './peertube-mobile-buttons' -import videojs from 'video.js' -import debug from 'debug' - -const logger = debug('peertube:player:mobile') - -const Plugin = videojs.getPlugin('plugin') - -class PeerTubeMobilePlugin extends Plugin { - private static readonly DOUBLE_TAP_DELAY_MS = 250 - private static readonly SET_CURRENT_TIME_DELAY = 1000 - - private peerTubeMobileButtons: PeerTubeMobileButtons - - private seekAmount = 0 - - private lastTapEvent: TouchEvent - private tapTimeout: ReturnType - private newActiveState: boolean - - private setCurrentTimeTimeout: ReturnType - - constructor (player: videojs.Player, options: videojs.PlayerOptions) { - super(player, options) - - this.peerTubeMobileButtons = player.addChild('PeerTubeMobileButtons', { reportTouchActivity: false }) as PeerTubeMobileButtons - - if (videojs.browser.IS_ANDROID && screen.orientation) { - this.handleFullscreenRotation() - } - - if (!this.player.options_.userActions) this.player.options_.userActions = {}; - - // FIXME: typings - (this.player.options_.userActions as any).click = false - this.player.options_.userActions.doubleClick = false - - this.player.one('play', () => { - this.initTouchStartEvents() - }) - } - - private handleFullscreenRotation () { - this.player.on('fullscreenchange', () => { - if (!this.player.isFullscreen() || this.isPortraitVideo()) return - - screen.orientation.lock('landscape') - .catch(err => console.error('Cannot lock screen to landscape.', err)) - }) - } - - private isPortraitVideo () { - return this.player.videoWidth() < this.player.videoHeight() - } - - private initTouchStartEvents () { - const handleTouchStart = (event: TouchEvent) => { - if (this.tapTimeout) { - clearTimeout(this.tapTimeout) - this.tapTimeout = undefined - } - - if (this.lastTapEvent && event.timeStamp - this.lastTapEvent.timeStamp < PeerTubeMobilePlugin.DOUBLE_TAP_DELAY_MS) { - logger('Detected double tap') - - this.lastTapEvent = undefined - this.onDoubleTap(event) - return - } - - this.newActiveState = !this.player.userActive() - - this.tapTimeout = setTimeout(() => { - logger('No double tap detected, set user active state to %s.', this.newActiveState) - - this.player.userActive(this.newActiveState) - }, PeerTubeMobilePlugin.DOUBLE_TAP_DELAY_MS) - - this.lastTapEvent = event - } - - this.player.on('touchstart', (event: TouchEvent) => { - // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it - if (this.player.userActive()) return - - handleTouchStart(event) - }) - - this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => { - // Prevent mousemove/click events firing on the player, that conflict with our user active logic - event.preventDefault() - - handleTouchStart(event) - }, { passive: false }) - } - - private onDoubleTap (event: TouchEvent) { - const playerWidth = this.player.currentWidth() - - const rect = this.findPlayerTarget((event.target as HTMLElement)).getBoundingClientRect() - const offsetX = event.targetTouches[0].pageX - rect.left - - logger('Calculating double tap zone (player width: %d, offset X: %d)', playerWidth, offsetX) - - if (offsetX > 0.66 * playerWidth) { - if (this.seekAmount < 0) this.seekAmount = 0 - - this.seekAmount += 10 - - logger('Will forward %d seconds', this.seekAmount) - } else if (offsetX < 0.33 * playerWidth) { - if (this.seekAmount > 0) this.seekAmount = 0 - - this.seekAmount -= 10 - logger('Will rewind %d seconds', this.seekAmount) - } - - this.peerTubeMobileButtons.displayFastSeek(this.seekAmount) - - this.scheduleSetCurrentTime() - } - - private findPlayerTarget (target: HTMLElement): HTMLElement { - if (target.classList.contains('video-js')) return target - - return this.findPlayerTarget(target.parentElement) - } - - private scheduleSetCurrentTime () { - this.player.pause() - this.player.addClass('vjs-fast-seeking') - - if (this.setCurrentTimeTimeout) clearTimeout(this.setCurrentTimeTimeout) - - this.setCurrentTimeTimeout = setTimeout(() => { - let newTime = this.player.currentTime() + this.seekAmount - this.seekAmount = 0 - - newTime = Math.max(0, newTime) - newTime = Math.min(this.player.duration(), newTime) - - this.player.currentTime(newTime) - this.seekAmount = 0 - this.peerTubeMobileButtons.displayFastSeek(0) - - this.player.removeClass('vjs-fast-seeking') - this.player.userActive(false) - - this.player.play() - }, PeerTubeMobilePlugin.SET_CURRENT_TIME_DELAY) - } -} - -videojs.registerPlugin('peertubeMobile', PeerTubeMobilePlugin) -export { PeerTubeMobilePlugin } diff --git a/client/src/assets/player/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/p2p-media-loader/hls-plugin.ts deleted file mode 100644 index ccee2d90f..000000000 --- a/client/src/assets/player/p2p-media-loader/hls-plugin.ts +++ /dev/null @@ -1,419 +0,0 @@ -// Thanks https://github.com/streamroot/videojs-hlsjs-plugin -// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file - -import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js' -import videojs from 'video.js' -import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../peertube-videojs-typings' - -type ErrorCounts = { - [ type: string ]: number -} - -type Metadata = { - levels: Level[] -} - -type HookFn = (player: videojs.Player, hljs: Hlsjs) => void - -const registerSourceHandler = function (vjs: typeof videojs) { - if (!Hlsjs.isSupported()) { - console.warn('Hls.js is not supported in this browser!') - return - } - - const html5 = vjs.getTech('Html5') - - if (!html5) { - console.error('No Hml5 tech found in videojs') - return - } - - // FIXME: typings - (html5 as any).registerSourceHandler({ - canHandleSource: function (source: videojs.Tech.SourceObject) { - const hlsTypeRE = /^application\/x-mpegURL|application\/vnd\.apple\.mpegurl$/i - const hlsExtRE = /\.m3u8/i - - if (hlsTypeRE.test(source.type)) return 'probably' - if (hlsExtRE.test(source.src)) return 'maybe' - - return '' - }, - - handleSource: function (source: videojs.Tech.SourceObject, tech: VideoJSTechHLS) { - if (tech.hlsProvider) { - tech.hlsProvider.dispose() - } - - tech.hlsProvider = new Html5Hlsjs(vjs, source, tech) - - return tech.hlsProvider - } - }, 0); - - // FIXME: typings - (vjs as any).Html5Hlsjs = Html5Hlsjs -} - -function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { - const player = this - - if (!options) return - - if (!player.srOptions_) { - player.srOptions_ = {} - } - - if (!player.srOptions_.hlsjsConfig) { - player.srOptions_.hlsjsConfig = options.hlsjsConfig - } - - if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { - player.srOptions_.levelLabelHandler = options.levelLabelHandler - } -} - -const registerConfigPlugin = function (vjs: typeof videojs) { - // Used in Brightcove since we don't pass options directly there - const registerVjsPlugin = vjs.registerPlugin || vjs.plugin - registerVjsPlugin('hlsjs', hlsjsConfigHandler) -} - -class Html5Hlsjs { - private static readonly hooks: { [id: string]: HookFn[] } = {} - - private readonly videoElement: HTMLVideoElement - private readonly errorCounts: ErrorCounts = {} - private readonly player: videojs.Player - private readonly tech: videojs.Tech - private readonly source: videojs.Tech.SourceObject - private readonly vjs: typeof videojs - - private maxNetworkErrorRecovery = 5 - - private hls: Hlsjs - private hlsjsConfig: Partial = null - - private _duration: number = null - private metadata: Metadata = null - private isLive: boolean = null - private dvrDuration: number = null - private edgeMargin: number = null - - private handlers: { [ id in 'play' ]: EventListener } = { - play: null - } - - constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { - this.vjs = vjs - this.source = source - - this.tech = tech; - (this.tech as any).name_ = 'Hlsjs' - - this.videoElement = tech.el() as HTMLVideoElement - this.player = vjs((tech.options_ as any).playerId) - - this.videoElement.addEventListener('error', event => { - let errorTxt: string - const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error - - if (!mediaError) return - - console.log(mediaError) - switch (mediaError.code) { - case mediaError.MEDIA_ERR_ABORTED: - errorTxt = 'You aborted the video playback' - break - case mediaError.MEDIA_ERR_DECODE: - errorTxt = 'The video playback was aborted due to a corruption problem or because the video used features ' + - 'your browser did not support' - this._handleMediaError(mediaError) - break - case mediaError.MEDIA_ERR_NETWORK: - errorTxt = 'A network error caused the video download to fail part-way' - break - case mediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: - errorTxt = 'The video could not be loaded, either because the server or network failed or because the format is not supported' - break - - default: - errorTxt = mediaError.message - } - - console.error('MEDIA_ERROR: ', errorTxt) - }) - - this.initialize() - } - - duration () { - if (this._duration === Infinity) return Infinity - if (!isNaN(this.videoElement.duration)) return this.videoElement.duration - - return this._duration || 0 - } - - seekable () { - if (this.hls.media) { - if (!this.isLive) { - return this.vjs.createTimeRanges(0, this.hls.media.duration) - } - - // Video.js doesn't seem to like floating point timeranges - const startTime = Math.round(this.hls.media.duration - this.dvrDuration) - const endTime = Math.round(this.hls.media.duration - this.edgeMargin) - - return this.vjs.createTimeRanges(startTime, endTime) - } - - return this.vjs.createTimeRanges() - } - - // See comment for `initialize` method. - dispose () { - this.videoElement.removeEventListener('play', this.handlers.play) - - // FIXME: https://github.com/video-dev/hls.js/issues/4092 - const untypedHLS = this.hls as any - untypedHLS.log = untypedHLS.warn = () => { - // empty - } - - this.hls.destroy() - } - - static addHook (type: string, callback: HookFn) { - Html5Hlsjs.hooks[type] = this.hooks[type] || [] - Html5Hlsjs.hooks[type].push(callback) - } - - static removeHook (type: string, callback: HookFn) { - if (Html5Hlsjs.hooks[type] === undefined) return false - - const index = Html5Hlsjs.hooks[type].indexOf(callback) - if (index === -1) return false - - Html5Hlsjs.hooks[type].splice(index, 1) - - return true - } - - private _executeHooksFor (type: string) { - if (Html5Hlsjs.hooks[type] === undefined) { - return - } - - // ES3 and IE < 9 - for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) { - Html5Hlsjs.hooks[type][i](this.player, this.hls) - } - } - - private _handleMediaError (error: any) { - if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 1) { - console.info('trying to recover media error') - this.hls.recoverMediaError() - return - } - - if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 2) { - console.info('2nd try to recover media error (by swapping audio codec') - this.hls.swapAudioCodec() - this.hls.recoverMediaError() - return - } - - if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] > 2) { - console.info('bubbling media error up to VIDEOJS') - this.hls.destroy() - this.tech.error = () => error - this.tech.trigger('error') - } - } - - private _handleNetworkError (error: any) { - if (this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] <= this.maxNetworkErrorRecovery) { - console.info('trying to recover network error') - - // Wait 1 second and retry - setTimeout(() => this.hls.startLoad(), 1000) - - // Reset error count on success - this.hls.once(Hlsjs.Events.FRAG_LOADED, () => { - this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] = 0 - }) - - return - } - - console.info('bubbling network error up to VIDEOJS') - this.hls.destroy() - this.tech.error = () => error - this.tech.trigger('error') - } - - private _onError (_event: any, data: ErrorData) { - const error: { message: string, code?: number } = { - message: `HLS.js error: ${data.type} - fatal: ${data.fatal} - ${data.details}` - } - - // increment/set error count - if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1 - else this.errorCounts[data.type] = 1 - - if (data.fatal) console.warn(error.message) - else console.error(error.message, data) - - if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) { - error.code = 2 - this._handleNetworkError(error) - } else if (data.fatal && data.type === Hlsjs.ErrorTypes.MEDIA_ERROR && data.details !== 'manifestIncompatibleCodecsError') { - error.code = 3 - this._handleMediaError(error) - } else if (data.fatal) { - this.hls.destroy() - console.info('bubbling error up to VIDEOJS') - this.tech.error = () => error as any - this.tech.trigger('error') - } - } - - private buildLevelLabel (level: Level) { - if (this.player.srOptions_.levelLabelHandler) { - return this.player.srOptions_.levelLabelHandler(level as any) - } - - if (level.height) return level.height + 'p' - if (level.width) return Math.round(level.width * 9 / 16) + 'p' - if (level.bitrate) return (level.bitrate / 1000) + 'kbps' - - return '0' - } - - private _notifyVideoQualities () { - if (!this.metadata) return - - const resolutions: PeerTubeResolution[] = [] - - this.metadata.levels.forEach((level, index) => { - resolutions.push({ - id: index, - height: level.height, - width: level.width, - bitrate: level.bitrate, - label: this.buildLevelLabel(level), - selected: level.id === this.hls.manualLevel, - - selectCallback: () => { - this.hls.currentLevel = index - } - }) - }) - - resolutions.push({ - id: -1, - label: this.player.localize('Auto'), - selected: true, - selectCallback: () => this.hls.currentLevel = -1 - }) - - this.player.peertubeResolutions().add(resolutions) - } - - private _startLoad () { - this.hls.startLoad(-1) - this.videoElement.removeEventListener('play', this.handlers.play) - } - - private _oneLevelObjClone (obj: { [ id: string ]: any }) { - const result = {} - const objKeys = Object.keys(obj) - for (let i = 0; i < objKeys.length; i++) { - result[objKeys[i]] = obj[objKeys[i]] - } - - return result - } - - private _onMetaData (_event: any, data: ManifestParsedData) { - // This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later - this.metadata = data - this._notifyVideoQualities() - } - - private _initHlsjs () { - const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions - const srOptions_ = this.player.srOptions_ - - const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig - // Hls.js will write to the reference thus change the object for later streams - this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {} - - if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) { - this.hlsjsConfig.autoStartLoad = false - } - - // If the user explicitly sets autoStartLoad to false, we're not going to enter the if block above - // That's why we have a separate if block here to set the 'play' listener - if (this.hlsjsConfig.autoStartLoad === false) { - this.handlers.play = this._startLoad.bind(this) - this.videoElement.addEventListener('play', this.handlers.play) - } - - this.hls = new Hlsjs(this.hlsjsConfig) - - this._executeHooksFor('beforeinitialize') - - this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data)) - this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data)) - this.hls.on(Hlsjs.Events.LEVEL_LOADED, (event, data) => { - // The DVR plugin will auto seek to "live edge" on start up - if (this.hlsjsConfig.liveSyncDuration) { - this.edgeMargin = this.hlsjsConfig.liveSyncDuration - } else if (this.hlsjsConfig.liveSyncDurationCount) { - this.edgeMargin = this.hlsjsConfig.liveSyncDurationCount * data.details.targetduration - } - - this.isLive = data.details.live - this.dvrDuration = data.details.totalduration - - this._duration = this.isLive ? Infinity : data.details.totalduration - - // Increase network error recovery for lives since they can be broken (server restart, stream interruption etc) - if (this.isLive) this.maxNetworkErrorRecovery = 300 - }) - - this.hls.once(Hlsjs.Events.FRAG_LOADED, () => { - // Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls` - // Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata - this.tech.trigger('loadedmetadata') - }) - - this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => { - const resolutionId = this.hls.autoLevelEnabled - ? -1 - : data.level - - const autoResolutionChosenId = this.hls.autoLevelEnabled - ? data.level - : -1 - - this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) - }) - - this.hls.attachMedia(this.videoElement) - - this.hls.loadSource(this.source.src) - } - - private initialize () { - this._initHlsjs() - } -} - -export { - Html5Hlsjs, - registerSourceHandler, - registerConfigPlugin -} diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts deleted file mode 100644 index 1d7a39b4e..000000000 --- a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts +++ /dev/null @@ -1,183 +0,0 @@ -import Hlsjs from 'hls.js' -import videojs from 'video.js' -import { Events, Segment } from '@peertube/p2p-media-loader-core' -import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' -import { timeToInt } from '@shared/core-utils' -import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../peertube-videojs-typings' -import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' - -registerConfigPlugin(videojs) -registerSourceHandler(videojs) - -const Plugin = videojs.getPlugin('plugin') -class P2pMediaLoaderPlugin extends Plugin { - - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000 // Don't change this - } - private readonly options: P2PMediaLoaderPluginOptions - - private hlsjs: Hlsjs - private p2pEngine: Engine - private statsP2PBytes = { - pendingDownload: [] as number[], - pendingUpload: [] as number[], - numPeers: 0, - totalDownload: 0, - totalUpload: 0 - } - private statsHTTPBytes = { - pendingDownload: [] as number[], - pendingUpload: [] as number[], - totalDownload: 0, - totalUpload: 0 - } - private startTime: number - - private networkInfoInterval: any - - constructor (player: videojs.Player, options?: P2PMediaLoaderPluginOptions) { - super(player) - - this.options = options - - // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 - if (!(videojs as any).Html5Hlsjs) { - console.warn('HLS.js does not seem to be supported. Try to fallback to built in HLS.') - - if (!player.canPlayType('application/vnd.apple.mpegurl')) { - const message = 'Cannot fallback to built-in HLS' - console.warn(message) - - player.ready(() => player.trigger('error', new Error(message))) - return - } - } else { - // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 - (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { - this.hlsjs = hlsjs - }) - - initVideoJsContribHlsJsPlayer(player) - } - - this.startTime = timeToInt(options.startTime) - - player.src({ - type: options.type, - src: options.src - }) - - player.ready(() => { - this.initializeCore() - - if ((videojs as any).Html5Hlsjs) { - this.initializePlugin() - } - }) - } - - dispose () { - if (this.hlsjs) this.hlsjs.destroy() - if (this.p2pEngine) this.p2pEngine.destroy() - - clearInterval(this.networkInfoInterval) - } - - getCurrentLevel () { - return this.hlsjs.levels[this.hlsjs.currentLevel] - } - - getLiveLatency () { - return Math.round(this.hlsjs.latency) - } - - getHLSJS () { - return this.hlsjs - } - - private initializeCore () { - this.player.one('play', () => { - this.player.addClass('vjs-has-big-play-button-clicked') - }) - - this.player.one('canplay', () => { - if (this.startTime) { - this.player.currentTime(this.startTime) - } - }) - } - - private initializePlugin () { - initHlsJsPlayer(this.hlsjs) - - this.p2pEngine = this.options.loader.getEngine() - - this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => { - console.error('Segment error.', segment, err) - - this.options.redundancyUrlManager.removeBySegmentUrl(segment.requestUrl) - }) - - this.statsP2PBytes.numPeers = 1 + this.options.redundancyUrlManager.countBaseUrls() - - this.runStats() - } - - private runStats () { - this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, _segment, bytes: number) => { - const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes - - elem.pendingDownload.push(bytes) - elem.totalDownload += bytes - }) - - this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, _segment, bytes: number) => { - const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes - - elem.pendingUpload.push(bytes) - elem.totalUpload += bytes - }) - - this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++) - this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--) - - this.networkInfoInterval = setInterval(() => { - const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload) - const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload) - - const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload) - const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload) - - this.statsP2PBytes.pendingDownload = [] - this.statsP2PBytes.pendingUpload = [] - this.statsHTTPBytes.pendingDownload = [] - this.statsHTTPBytes.pendingUpload = [] - - return this.player.trigger('p2pInfo', { - source: 'p2p-media-loader', - http: { - downloadSpeed: httpDownloadSpeed, - uploadSpeed: httpUploadSpeed, - downloaded: this.statsHTTPBytes.totalDownload, - uploaded: this.statsHTTPBytes.totalUpload - }, - p2p: { - downloadSpeed: p2pDownloadSpeed, - uploadSpeed: p2pUploadSpeed, - numPeers: this.statsP2PBytes.numPeers, - downloaded: this.statsP2PBytes.totalDownload, - uploaded: this.statsP2PBytes.totalUpload - }, - bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 - } as PlayerNetworkInfo) - }, this.CONSTANTS.INFO_SCHEDULER) - } - - private arraySum (data: number[]) { - return data.reduce((a: number, b: number) => a + b, 0) - } -} - -videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) -export { P2pMediaLoaderPlugin } diff --git a/client/src/assets/player/p2p-media-loader/redundancy-url-manager.ts b/client/src/assets/player/p2p-media-loader/redundancy-url-manager.ts deleted file mode 100644 index abab8aa99..000000000 --- a/client/src/assets/player/p2p-media-loader/redundancy-url-manager.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { basename, dirname } from 'path' - -class RedundancyUrlManager { - - constructor (private baseUrls: string[] = []) { - // empty - } - - removeBySegmentUrl (segmentUrl: string) { - console.log('Removing redundancy of segment URL %s.', segmentUrl) - - const baseUrl = dirname(segmentUrl) - - this.baseUrls = this.baseUrls.filter(u => u !== baseUrl && u !== baseUrl + '/') - } - - buildUrl (url: string) { - const max = this.baseUrls.length + 1 - const i = this.getRandomInt(max) - - if (i === max - 1) return url - - const newBaseUrl = this.baseUrls[i] - const slashPart = newBaseUrl.endsWith('/') ? '' : '/' - - return newBaseUrl + slashPart + basename(url) - } - - countBaseUrls () { - return this.baseUrls.length - } - - private getRandomInt (max: number) { - return Math.floor(Math.random() * Math.floor(max)) - } -} - -// --------------------------------------------------------------------------- - -export { - RedundancyUrlManager -} diff --git a/client/src/assets/player/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/p2p-media-loader/segment-url-builder.ts deleted file mode 100644 index 9d324078a..000000000 --- a/client/src/assets/player/p2p-media-loader/segment-url-builder.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Segment } from '@peertube/p2p-media-loader-core' -import { RedundancyUrlManager } from './redundancy-url-manager' - -function segmentUrlBuilderFactory (redundancyUrlManager: RedundancyUrlManager, useOriginPriority: number) { - return function segmentBuilder (segment: Segment) { - // Don't use redundancy for high priority segments - if (segment.priority <= useOriginPriority) return segment.url - - return redundancyUrlManager.buildUrl(segment.url) - } -} - -// --------------------------------------------------------------------------- - -export { - segmentUrlBuilderFactory -} diff --git a/client/src/assets/player/p2p-media-loader/segment-validator.ts b/client/src/assets/player/p2p-media-loader/segment-validator.ts deleted file mode 100644 index f7f83a8a4..000000000 --- a/client/src/assets/player/p2p-media-loader/segment-validator.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { wait } from '@root-helpers/utils' -import { Segment } from '@peertube/p2p-media-loader-core' -import { basename } from 'path' - -type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } } - -const maxRetries = 3 - -function segmentValidatorFactory (segmentsSha256Url: string, isLive: boolean) { - let segmentsJSON = fetchSha256Segments(segmentsSha256Url) - const regex = /bytes=(\d+)-(\d+)/ - - return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { - // Wait for hash generation from the server - if (isLive) await wait(1000) - - const filename = basename(segment.url) - - const segmentValue = (await segmentsJSON)[filename] - - if (!segmentValue && retry > maxRetries) { - throw new Error(`Unknown segment name ${filename} in segment validator`) - } - - if (!segmentValue) { - console.log('Refetching sha segments for %s.', filename) - - await wait(1000) - - segmentsJSON = fetchSha256Segments(segmentsSha256Url) - await segmentValidator(segment, _method, _peerId, retry + 1) - - return - } - - let hashShouldBe: string - let range = '' - - if (typeof segmentValue === 'string') { - hashShouldBe = segmentValue - } else { - const captured = regex.exec(segment.range) - range = captured[1] + '-' + captured[2] - - hashShouldBe = segmentValue[range] - } - - if (hashShouldBe === undefined) { - throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) - } - - const calculatedSha = await sha256Hex(segment.data) - if (calculatedSha !== hashShouldBe) { - throw new Error( - `Hashes does not correspond for segment ${filename}/${range}` + - `(expected: ${hashShouldBe} instead of ${calculatedSha})` - ) - } - } -} - -// --------------------------------------------------------------------------- - -export { - segmentValidatorFactory -} - -// --------------------------------------------------------------------------- - -function fetchSha256Segments (url: string) { - return fetch(url) - .then(res => res.json() as Promise) - .catch(err => { - console.error('Cannot get sha256 segments', err) - return {} - }) -} - -async function sha256Hex (data?: ArrayBuffer) { - if (!data) return undefined - - if (window.crypto.subtle) { - return window.crypto.subtle.digest('SHA-256', data) - .then(data => bufferToHex(data)) - } - - // Fallback for non HTTPS context - const shaModule = (await import('sha.js') as any).default - // eslint-disable-next-line new-cap - return new shaModule.sha256().update(Buffer.from(data)).digest('hex') -} - -// Thanks: https://stackoverflow.com/a/53307879 -function bufferToHex (buffer?: ArrayBuffer) { - if (!buffer) return '' - - let s = '' - const h = '0123456789abcdef' - const o = new Uint8Array(buffer) - - o.forEach((v: any) => { - s += h[v >> 4] + h[v & 15] - }) - - return s -} diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index ddb521a52..1b2a67c77 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -1,35 +1,34 @@ import '@peertube/videojs-contextmenu' -import './upnext/end-card' -import './upnext/upnext-plugin' -import './stats/stats-card' -import './stats/stats-plugin' -import './bezels/bezels-plugin' -import './peertube-plugin' -import './peertube-resolutions-plugin' -import './control-bar/next-previous-video-button' -import './control-bar/p2p-info-button' -import './control-bar/peertube-link-button' -import './control-bar/peertube-load-progress-bar' -import './control-bar/theater-button' -import './settings/resolution-menu-button' -import './settings/resolution-menu-item' -import './settings/settings-dialog' -import './settings/settings-menu-button' -import './settings/settings-menu-item' -import './settings/settings-panel' -import './settings/settings-panel-child' -import './playlist/playlist-plugin' -import './mobile/peertube-mobile-plugin' -import './mobile/peertube-mobile-buttons' -import './hotkeys/peertube-hotkeys-plugin' +import './shared/upnext/end-card' +import './shared/upnext/upnext-plugin' +import './shared/stats/stats-card' +import './shared/stats/stats-plugin' +import './shared/bezels/bezels-plugin' +import './shared/peertube/peertube-plugin' +import './shared/resolutions/peertube-resolutions-plugin' +import './shared/control-bar/next-previous-video-button' +import './shared/control-bar/p2p-info-button' +import './shared/control-bar/peertube-link-button' +import './shared/control-bar/peertube-load-progress-bar' +import './shared/control-bar/theater-button' +import './shared/settings/resolution-menu-button' +import './shared/settings/resolution-menu-item' +import './shared/settings/settings-dialog' +import './shared/settings/settings-menu-button' +import './shared/settings/settings-menu-item' +import './shared/settings/settings-panel' +import './shared/settings/settings-panel-child' +import './shared/playlist/playlist-plugin' +import './shared/mobile/peertube-mobile-plugin' +import './shared/mobile/peertube-mobile-buttons' +import './shared/hotkeys/peertube-hotkeys-plugin' import videojs from 'video.js' import { PluginsManager } from '@root-helpers/plugins-manager' -import { ManagerOptionsBuilder } from './manager-options/manager-options-builder' -import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from './manager-options/manager-options.model' +import { isMobile } from '@root-helpers/web-browser' import { saveAverageBandwidth } from './peertube-player-local-storage' -import { PlayerNetworkInfo } from './peertube-videojs-typings' +import { ManagerOptionsBuilder } from './shared/manager-options' import { TranslationsManager } from './translations-manager' -import { isMobile } from './utils' +import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode, PlayerNetworkInfo } from './types' // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' @@ -60,11 +59,11 @@ export class PeertubePlayerManager { this.onPlayerChange = onPlayerChange this.playerElementClassName = options.common.playerElement.className - if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') + if (mode === 'webtorrent') await import('./shared/webtorrent/webtorrent-plugin') if (mode === 'p2p-media-loader') { const [ p2pMediaLoaderModule ] = await Promise.all([ import('@peertube/p2p-media-loader-hlsjs'), - import('./p2p-media-loader/p2p-media-loader-plugin') + import('./shared/p2p-media-loader/p2p-media-loader-plugin') ]) this.p2pMediaLoaderModule = p2pMediaLoaderModule @@ -174,7 +173,7 @@ export class PeertubePlayerManager { this.rebuildAndUpdateVideoElement(currentPlayer, options.common) - await import('./webtorrent/webtorrent-plugin') + await import('./shared/webtorrent/webtorrent-plugin') const newPlayer = await this.buildPlayer('webtorrent', options) this.onPlayerChange(newPlayer) diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts deleted file mode 100644 index 4ffc9ce3e..000000000 --- a/client/src/assets/player/peertube-plugin.ts +++ /dev/null @@ -1,302 +0,0 @@ -import debug from 'debug' -import videojs from 'video.js' -import { timeToInt } from '@shared/core-utils' -import { - getStoredLastSubtitle, - getStoredMute, - getStoredVolume, - saveLastSubtitle, - saveMuteInStore, - saveVideoWatchHistory, - saveVolumeInStore -} from './peertube-player-local-storage' -import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from './peertube-videojs-typings' -import { SettingsButton } from './settings/settings-menu-button' -import { isMobile } from './utils' - -const logger = debug('peertube:player:peertube') - -const Plugin = videojs.getPlugin('plugin') - -class PeerTubePlugin extends Plugin { - private readonly videoViewUrl: string - private readonly videoDuration: number - private readonly CONSTANTS = { - USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video - } - - private videoCaptions: VideoJSCaption[] - private defaultSubtitle: string - - private videoViewInterval: any - private userWatchingVideoInterval: any - - private isLive: boolean - - private menuOpened = false - private mouseInControlBar = false - private mouseInSettings = false - private readonly initialInactivityTimeout: number - - constructor (player: videojs.Player, options?: PeerTubePluginOptions) { - super(player) - - this.videoViewUrl = options.videoViewUrl - this.videoDuration = options.videoDuration - this.videoCaptions = options.videoCaptions - this.isLive = options.isLive - this.initialInactivityTimeout = this.player.options_.inactivityTimeout - - if (options.autoplay) this.player.addClass('vjs-has-autoplay') - - this.player.on('autoplay-failure', () => { - this.player.removeClass('vjs-has-autoplay') - }) - - this.player.ready(() => { - const playerOptions = this.player.options_ - - const volume = getStoredVolume() - if (volume !== undefined) this.player.volume(volume) - - const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() - if (muted !== undefined) this.player.muted(muted) - - this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() - - this.player.on('volumechange', () => { - saveVolumeInStore(this.player.volume()) - saveMuteInStore(this.player.muted()) - }) - - if (options.stopTime) { - const stopTime = timeToInt(options.stopTime) - const self = this - - this.player.on('timeupdate', function onTimeUpdate () { - if (self.player.currentTime() > stopTime) { - self.player.pause() - self.player.trigger('stopped') - - self.player.off('timeupdate', onTimeUpdate) - } - }) - } - - this.player.textTracks().addEventListener('change', () => { - const showing = this.player.textTracks().tracks_.find(t => { - return t.kind === 'captions' && t.mode === 'showing' - }) - - if (!showing) { - saveLastSubtitle('off') - return - } - - saveLastSubtitle(showing.language) - }) - - this.player.on('sourcechange', () => this.initCaptions()) - - this.player.duration(options.videoDuration) - - this.initializePlayer() - this.runViewAdd() - - this.runUserWatchVideo(options.userWatching, options.videoUUID) - }) - } - - dispose () { - if (this.videoViewInterval) clearInterval(this.videoViewInterval) - if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) - } - - onMenuOpened () { - this.menuOpened = true - this.alterInactivity() - } - - onMenuClosed () { - this.menuOpened = false - this.alterInactivity() - } - - displayFatalError () { - this.player.addClass('vjs-error-display-enabled') - } - - hideFatalError () { - this.player.removeClass('vjs-error-display-enabled') - } - - private initializePlayer () { - if (isMobile()) this.player.addClass('vjs-is-mobile') - - this.initSmoothProgressBar() - - this.initCaptions() - - this.listenControlBarMouse() - - this.listenFullScreenChange() - } - - private runViewAdd () { - this.clearVideoViewInterval() - - // After 30 seconds (or 3/4 of the video), add a view to the video - let minSecondsToView = 30 - - if (!this.isLive && this.videoDuration < minSecondsToView) { - minSecondsToView = (this.videoDuration * 3) / 4 - } - - let secondsViewed = 0 - this.videoViewInterval = setInterval(() => { - if (this.player && !this.player.paused()) { - secondsViewed += 1 - - if (secondsViewed > minSecondsToView) { - // Restart the loop if this is a live - if (this.isLive) { - secondsViewed = 0 - } else { - this.clearVideoViewInterval() - } - - this.addViewToVideo().catch(err => console.error(err)) - } - } - }, 1000) - } - - private runUserWatchVideo (options: UserWatching, videoUUID: string) { - let lastCurrentTime = 0 - - this.userWatchingVideoInterval = setInterval(() => { - const currentTime = Math.floor(this.player.currentTime()) - - if (currentTime - lastCurrentTime >= 1) { - lastCurrentTime = currentTime - - if (options) { - this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) - .catch(err => console.error('Cannot notify user is watching.', err)) - } else { - saveVideoWatchHistory(videoUUID, currentTime) - } - } - }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) - } - - private clearVideoViewInterval () { - if (this.videoViewInterval !== undefined) { - clearInterval(this.videoViewInterval) - this.videoViewInterval = undefined - } - } - - private addViewToVideo () { - if (!this.videoViewUrl) return Promise.resolve(undefined) - - return fetch(this.videoViewUrl, { method: 'POST' }) - } - - private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { - const body = new URLSearchParams() - body.append('currentTime', currentTime.toString()) - - const headers = new Headers({ Authorization: authorizationHeader }) - - return fetch(url, { method: 'PUT', body, headers }) - } - - private listenFullScreenChange () { - this.player.on('fullscreenchange', () => { - if (this.player.isFullscreen()) this.player.focus() - }) - } - - private listenControlBarMouse () { - const controlBar = this.player.controlBar - const settingsButton: SettingsButton = (controlBar as any).settingsButton - - controlBar.on('mouseenter', () => { - this.mouseInControlBar = true - this.alterInactivity() - }) - - controlBar.on('mouseleave', () => { - this.mouseInControlBar = false - this.alterInactivity() - }) - - settingsButton.dialog.on('mouseenter', () => { - this.mouseInSettings = true - this.alterInactivity() - }) - - settingsButton.dialog.on('mouseleave', () => { - this.mouseInSettings = false - this.alterInactivity() - }) - } - - private alterInactivity () { - if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) { - this.setInactivityTimeout(0) - return - } - - this.setInactivityTimeout(this.initialInactivityTimeout) - this.player.reportUserActivity(true) - } - - private setInactivityTimeout (timeout: number) { - (this.player as any).cache_.inactivityTimeout = timeout - this.player.options_.inactivityTimeout = timeout - - logger('Set player inactivity to ' + timeout) - } - - private initCaptions () { - for (const caption of this.videoCaptions) { - this.player.addRemoteTextTrack({ - kind: 'captions', - label: caption.label, - language: caption.language, - id: caption.language, - src: caption.src, - default: this.defaultSubtitle === caption.language - }, false) - } - - this.player.trigger('captionsChanged') - } - - // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 - private initSmoothProgressBar () { - const SeekBar = videojs.getComponent('SeekBar') as any - SeekBar.prototype.getPercent = function getPercent () { - // Allows for smooth scrubbing, when player can't keep up. - // const time = (this.player_.scrubbing()) ? - // this.player_.getCache().currentTime : - // this.player_.currentTime() - const time = this.player_.currentTime() - const percent = time / this.player_.duration() - return percent >= 1 ? 1 : percent - } - SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { - let newTime = this.calculateDistance(event) * this.player_.duration() - if (newTime === this.player_.duration()) { - newTime = newTime - 0.1 - } - this.player_.currentTime(newTime) - this.update() - } - } -} - -videojs.registerPlugin('peertube', PeerTubePlugin) -export { PeerTubePlugin } diff --git a/client/src/assets/player/peertube-resolutions-plugin.ts b/client/src/assets/player/peertube-resolutions-plugin.ts deleted file mode 100644 index cc36f18f3..000000000 --- a/client/src/assets/player/peertube-resolutions-plugin.ts +++ /dev/null @@ -1,88 +0,0 @@ -import videojs from 'video.js' -import { PeerTubeResolution } from './peertube-videojs-typings' - -const Plugin = videojs.getPlugin('plugin') - -class PeerTubeResolutionsPlugin extends Plugin { - private currentSelection: PeerTubeResolution - private resolutions: PeerTubeResolution[] = [] - - private autoResolutionChosenId: number - private autoResolutionEnabled = true - - add (resolutions: PeerTubeResolution[]) { - for (const r of resolutions) { - this.resolutions.push(r) - } - - this.currentSelection = this.getSelected() - - this.sort() - this.trigger('resolutionsAdded') - } - - getResolutions () { - return this.resolutions - } - - getSelected () { - return this.resolutions.find(r => r.selected) - } - - getAutoResolutionChosen () { - return this.resolutions.find(r => r.id === this.autoResolutionChosenId) - } - - select (options: { - id: number - byEngine: boolean - autoResolutionChosenId?: number - }) { - const { id, autoResolutionChosenId, byEngine } = options - - if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return - - this.autoResolutionChosenId = autoResolutionChosenId - - for (const r of this.resolutions) { - r.selected = r.id === id - - if (r.selected) { - this.currentSelection = r - - if (!byEngine) r.selectCallback() - } - } - - this.trigger('resolutionChanged') - } - - disableAutoResolution () { - this.autoResolutionEnabled = false - this.trigger('autoResolutionEnabledChanged') - } - - enabledAutoResolution () { - this.autoResolutionEnabled = true - this.trigger('autoResolutionEnabledChanged') - } - - isAutoResolutionEnabeld () { - return this.autoResolutionEnabled - } - - private sort () { - this.resolutions.sort((a, b) => { - if (a.id === -1) return 1 - if (b.id === -1) return -1 - - if (a.height > b.height) return -1 - if (a.height === b.height) return 0 - return 1 - }) - } - -} - -videojs.registerPlugin('peertubeResolutions', PeerTubeResolutionsPlugin) -export { PeerTubeResolutionsPlugin } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts deleted file mode 100644 index fcaa8a9c3..000000000 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { HlsConfig, Level } from 'hls.js' -import videojs from 'video.js' -import { Engine } from '@peertube/p2p-media-loader-hlsjs' -import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' -import { PeerTubeDockPluginOptions } from './dock/peertube-dock-plugin' -import { PlayerMode } from './manager-options/manager-options.model' -import { Html5Hlsjs } from './p2p-media-loader/hls-plugin' -import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' -import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' -import { PeerTubePlugin } from './peertube-plugin' -import { PeerTubeResolutionsPlugin } from './peertube-resolutions-plugin' -import { PlaylistPlugin } from './playlist/playlist-plugin' -import { StatsCardOptions } from './stats/stats-card' -import { StatsForNerdsPlugin } from './stats/stats-plugin' -import { EndCardOptions } from './upnext/end-card' -import { WebTorrentPlugin } from './webtorrent/webtorrent-plugin' - -declare module 'video.js' { - - export interface VideoJsPlayer { - srOptions_: HlsjsConfigHandlerOptions - - theaterEnabled: boolean - - // FIXME: add it to upstream typings - posterImage: { - show (): void - hide (): void - } - - handleTechSeeked_ (): void - - // Plugins - - peertube (): PeerTubePlugin - - webtorrent (): WebTorrentPlugin - - p2pMediaLoader (): P2pMediaLoaderPlugin - - peertubeResolutions (): PeerTubeResolutionsPlugin - - contextmenuUI (options: any): any - - bezels (): void - peertubeMobile (): void - peerTubeHotkeysPlugin (): void - - stats (options?: StatsCardOptions): StatsForNerdsPlugin - - textTracks (): TextTrackList & { - tracks_: (TextTrack & { id: string, label: string, src: string })[] - } - - peertubeDock (options: PeerTubeDockPluginOptions): void - - upnext (options: Partial): void - - playlist (): PlaylistPlugin - } -} - -export interface VideoJSTechHLS extends videojs.Tech { - hlsProvider: Html5Hlsjs -} - -export interface HlsjsConfigHandlerOptions { - hlsjsConfig?: HlsConfig - - levelLabelHandler?: (level: Level) => string -} - -type PeerTubeResolution = { - id: number - - height?: number - label?: string - width?: number - bitrate?: number - - selected: boolean - selectCallback: () => void -} - -type VideoJSCaption = { - label: string - language: string - src: string -} - -type UserWatching = { - url: string - authorizationHeader: string -} - -type PeerTubePluginOptions = { - mode: PlayerMode - - autoplay: boolean - videoViewUrl: string - videoDuration: number - - userWatching?: UserWatching - subtitle?: string - - videoCaptions: VideoJSCaption[] - - stopTime: number | string - - isLive: boolean - - videoUUID: string -} - -type PlaylistPluginOptions = { - elements: VideoPlaylistElement[] - - playlist: VideoPlaylist - - getCurrentPosition: () => number - - onItemClicked: (element: VideoPlaylistElement) => void -} - -type NextPreviousVideoButtonOptions = { - type: 'next' | 'previous' - handler: () => void - isDisabled: () => boolean -} - -type PeerTubeLinkButtonOptions = { - shortUUID: string -} - -type PeerTubeP2PInfoButtonOptions = { - p2pEnabled: boolean -} - -type WebtorrentPluginOptions = { - playerElement: HTMLVideoElement - - autoplay: boolean - videoDuration: number - - videoFiles: VideoFile[] - - startTime: number | string - - playerRefusedP2P: boolean -} - -type P2PMediaLoaderPluginOptions = { - redundancyUrlManager: RedundancyUrlManager - type: string - src: string - - startTime: number | string - - loader: P2PMediaLoader -} - -export type P2PMediaLoader = { - getEngine(): Engine -} - -type VideoJSPluginOptions = { - playlist?: PlaylistPluginOptions - - peertube: PeerTubePluginOptions - - webtorrent?: WebtorrentPluginOptions - - p2pMediaLoader?: P2PMediaLoaderPluginOptions -} - -type LoadedQualityData = { - qualitySwitchCallback: (resolutionId: number, type: 'video') => void - qualityData: { - video: { - id: number - label: string - selected: boolean - }[] - } -} - -type ResolutionUpdateData = { - auto: boolean - resolutionId: number - id?: number -} - -type AutoResolutionUpdateData = { - possible: boolean -} - -type PlayerNetworkInfo = { - source: 'webtorrent' | 'p2p-media-loader' - - http: { - downloadSpeed: number - uploadSpeed: number - downloaded: number - uploaded: number - } - - p2p: { - downloadSpeed: number - uploadSpeed: number - downloaded: number - uploaded: number - numPeers: number - } - - // In bytes - bandwidthEstimate: number -} - -type PlaylistItemOptions = { - element: VideoPlaylistElement - - onClicked: () => void -} - -export { - PlayerNetworkInfo, - PlaylistItemOptions, - NextPreviousVideoButtonOptions, - ResolutionUpdateData, - AutoResolutionUpdateData, - PlaylistPluginOptions, - VideoJSCaption, - UserWatching, - PeerTubePluginOptions, - WebtorrentPluginOptions, - P2PMediaLoaderPluginOptions, - PeerTubeResolution, - VideoJSPluginOptions, - LoadedQualityData, - PeerTubeLinkButtonOptions, - PeerTubeP2PInfoButtonOptions -} diff --git a/client/src/assets/player/playlist/playlist-button.ts b/client/src/assets/player/playlist/playlist-button.ts deleted file mode 100644 index a7996ec60..000000000 --- a/client/src/assets/player/playlist/playlist-button.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 2519a34c7..000000000 --- a/client/src/assets/player/playlist/playlist-menu-item.ts +++ /dev/null @@ -1,136 +0,0 @@ -import videojs from 'video.js' -import { secondsToTime } from '@shared/core-utils' -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 - - if (!options.element.video) { - li.classList.add('vjs-disabled') - } - - const positionBlock = super.createEl('div', { - className: 'item-position-block' - }) as HTMLElement - - const position = super.createEl('div', { - className: 'item-position', - innerHTML: options.element.position - }) - - positionBlock.appendChild(position) - li.appendChild(positionBlock) - - if (options.element.video) { - this.buildAvailableVideo(li, positionBlock, options) - } else { - this.buildUnavailableVideo(li) - } - - return li - } - - setSelected (selected: boolean) { - if (selected) this.addClass('vjs-selected') - else this.removeClass('vjs-selected') - } - - getElement () { - return this.element - } - - private buildAvailableVideo (li: HTMLElement, positionBlock: HTMLElement, options: PlaylistItemOptions) { - const videoElement = options.element - - const player = super.createEl('div', { - className: 'item-player' - }) - - positionBlock.appendChild(player) - - const thumbnail = super.createEl('img', { - src: window.location.origin + videoElement.video.thumbnailPath - }) - - const infoBlock = super.createEl('div', { - className: 'info-block' - }) - - const title = super.createEl('div', { - innerHTML: videoElement.video.name, - className: 'title' - }) - - const channel = super.createEl('div', { - innerHTML: videoElement.video.channel.displayName, - className: 'channel' - }) - - infoBlock.appendChild(title) - infoBlock.appendChild(channel) - - if (videoElement.startTimestamp || videoElement.stopTimestamp) { - let html = '' - - if (videoElement.startTimestamp) html += secondsToTime(videoElement.startTimestamp) - if (videoElement.stopTimestamp) html += ' - ' + secondsToTime(videoElement.stopTimestamp) - - const timestamps = super.createEl('div', { - innerHTML: html, - className: 'timestamps' - }) - - infoBlock.append(timestamps) - } - - li.append(thumbnail) - li.append(infoBlock) - } - - private buildUnavailableVideo (li: HTMLElement) { - const block = super.createEl('div', { - className: 'item-unavailable', - innerHTML: this.player().localize('Unavailable video') - }) - - li.appendChild(block) - } - - 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 deleted file mode 100644 index a2583047b..000000000 --- a/client/src/assets/player/playlist/playlist-menu.ts +++ /dev/null @@ -1,137 +0,0 @@ -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) - - const self = this - - function userInactiveHandler () { - self.close() - } - - this.el().addEventListener('mouseenter', () => { - this.player().off('userinactive', userInactiveHandler) - }) - - this.el().addEventListener('mouseleave', () => { - this.player().one('userinactive', userInactiveHandler) - }) - - 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 playlistChannel = options.playlist.videoChannel - const leftSubtitle = super.createEl('div', { - innerHTML: playlistChannel - ? this.player().localize('By {1}', [ playlistChannel.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 deleted file mode 100644 index b69d82e3c..000000000 --- a/client/src/assets/player/playlist/playlist-plugin.ts +++ /dev/null @@ -1,35 +0,0 @@ -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/assets/player/settings/resolution-menu-button.ts b/client/src/assets/player/settings/resolution-menu-button.ts deleted file mode 100644 index 8bd5b4f03..000000000 --- a/client/src/assets/player/settings/resolution-menu-button.ts +++ /dev/null @@ -1,86 +0,0 @@ -import videojs from 'video.js' -import { ResolutionMenuItem } from './resolution-menu-item' - -const Menu = videojs.getComponent('Menu') -const MenuButton = videojs.getComponent('MenuButton') -class ResolutionMenuButton extends MenuButton { - labelEl_: HTMLElement - - constructor (player: videojs.Player, options?: videojs.MenuButtonOptions) { - super(player, options) - - this.controlText('Quality') - - player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) - - // For parent - player.peertubeResolutions().on('resolutionChanged', () => { - setTimeout(() => this.trigger('labelUpdated')) - }) - } - - createEl () { - const el = super.createEl() - - this.labelEl_ = videojs.dom.createEl('div', { - className: 'vjs-resolution-value' - }) as HTMLElement - - el.appendChild(this.labelEl_) - - return el - } - - updateARIAAttributes () { - this.el().setAttribute('aria-label', 'Quality') - } - - createMenu () { - return new Menu(this.player_) - } - - buildCSSClass () { - return super.buildCSSClass() + ' vjs-resolution-button' - } - - buildWrapperCSSClass () { - return 'vjs-resolution-control ' + super.buildWrapperCSSClass() - } - - private addClickListener (component: any) { - component.on('click', () => { - const children = this.menu.children() - - for (const child of children) { - if (component !== child) { - (child as videojs.MenuItem).selected(false) - } - } - }) - } - - private buildQualities () { - for (const d of this.player().peertubeResolutions().getResolutions()) { - const label = d.label === '0p' - ? this.player().localize('Audio-only') - : d.label - - this.menu.addChild(new ResolutionMenuItem( - this.player_, - { - id: d.id, - label, - selected: d.selected - }) - ) - } - - for (const m of this.menu.children()) { - this.addClickListener(m) - } - - this.trigger('menuChanged') - } -} - -videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton) diff --git a/client/src/assets/player/settings/resolution-menu-item.ts b/client/src/assets/player/settings/resolution-menu-item.ts deleted file mode 100644 index 6047f52f7..000000000 --- a/client/src/assets/player/settings/resolution-menu-item.ts +++ /dev/null @@ -1,77 +0,0 @@ -import videojs from 'video.js' - -const MenuItem = videojs.getComponent('MenuItem') - -export interface ResolutionMenuItemOptions extends videojs.MenuItemOptions { - id: number -} - -class ResolutionMenuItem extends MenuItem { - private readonly resolutionId: number - private readonly label: string - - private autoResolutionEnabled: boolean - private autoResolutionChosen: string - - constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { - options.selectable = true - - super(player, options) - - this.autoResolutionEnabled = true - this.autoResolutionChosen = '' - - this.resolutionId = options.id - this.label = options.label - - player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection()) - - // We only want to disable the "Auto" item - if (this.resolutionId === -1) { - player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution()) - } - } - - handleClick (event: any) { - // Auto button disabled? - if (this.autoResolutionEnabled === false && this.resolutionId === -1) return - - super.handleClick(event) - - this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) - } - - updateSelection () { - const selectedResolution = this.player().peertubeResolutions().getSelected() - - if (this.resolutionId === -1) { - this.autoResolutionChosen = this.player().peertubeResolutions().getAutoResolutionChosen()?.label - } - - this.selected(this.resolutionId === selectedResolution.id) - } - - updateAutoResolution () { - const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld() - - // Check if the auto resolution is enabled or not - if (enabled === false) { - this.addClass('disabled') - } else { - this.removeClass('disabled') - } - - this.autoResolutionEnabled = enabled - } - - getLabel () { - if (this.resolutionId === -1) { - return this.label + ' ' + this.autoResolutionChosen + '' - } - - return this.label - } -} -videojs.registerComponent('ResolutionMenuItem', ResolutionMenuItem) - -export { ResolutionMenuItem } diff --git a/client/src/assets/player/settings/settings-dialog.ts b/client/src/assets/player/settings/settings-dialog.ts deleted file mode 100644 index 8cd98967f..000000000 --- a/client/src/assets/player/settings/settings-dialog.ts +++ /dev/null @@ -1,35 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') - -class SettingsDialog extends Component { - constructor (player: videojs.Player) { - super(player) - - this.hide() - } - - /** - * Create the component's DOM element - * - */ - 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 - }) - } -} - -Component.registerComponent('SettingsDialog', SettingsDialog) - -export { SettingsDialog } diff --git a/client/src/assets/player/settings/settings-menu-button.ts b/client/src/assets/player/settings/settings-menu-button.ts deleted file mode 100644 index 6de390f4d..000000000 --- a/client/src/assets/player/settings/settings-menu-button.ts +++ /dev/null @@ -1,279 +0,0 @@ -// Thanks to Yanko Shterev: https://github.com/yshterev/videojs-settings-menu -import { SettingsMenuItem } from './settings-menu-item' -import { toTitleCase } from '../utils' -import videojs from 'video.js' - -import { SettingsDialog } from './settings-dialog' -import { SettingsPanel } from './settings-panel' -import { SettingsPanelChild } from './settings-panel-child' - -const Button = videojs.getComponent('Button') -const Menu = videojs.getComponent('Menu') -const Component = videojs.getComponent('Component') - -export interface SettingsButtonOptions extends videojs.ComponentOptions { - entries: any[] - setup?: { - maxHeightOffset: number - } -} - -class SettingsButton extends Button { - dialog: SettingsDialog - dialogEl: HTMLElement - menu: videojs.Menu - panel: SettingsPanel - panelChild: SettingsPanelChild - - addSettingsItemHandler: typeof SettingsButton.prototype.onAddSettingsItem - disposeSettingsItemHandler: typeof SettingsButton.prototype.onDisposeSettingsItem - documentClickHandler: typeof SettingsButton.prototype.onDocumentClick - userInactiveHandler: typeof SettingsButton.prototype.onUserInactive - - private settingsButtonOptions: SettingsButtonOptions - - constructor (player: videojs.Player, options?: SettingsButtonOptions) { - super(player, options) - - this.settingsButtonOptions = options - - this.controlText('Settings') - - this.dialog = this.player().addChild('settingsDialog') - this.dialogEl = this.dialog.el() as HTMLElement - 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.documentClickHandler = this.onDocumentClick.bind(this) - this.userInactiveHandler = this.onUserInactive.bind(this) - - this.buildMenu() - this.bindEvents() - - // Prepare the dialog - this.player().one('play', () => this.hideDialog()) - } - - onDocumentClick (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: any, name: string) { - if (name === undefined) { - const children = this.menu.children() - - while (children.length > 0) { - children[0].dispose() - this.menu.removeChild(children[0]) - } - - this.addClass('vjs-hidden') - } else { - const item = this.menu.getChild(name) - - if (item) { - item.dispose() - this.menu.removeChild(item) - } - } - - this.hideDialog() - - if (this.settingsButtonOptions.entries.length === 0) { - this.addClass('vjs-hidden') - } - } - - dispose () { - document.removeEventListener('click', this.documentClickHandler) - - if (this.isInIframe()) { - window.removeEventListener('blur', this.documentClickHandler) - } - } - - onAddSettingsItem (event: any, data: any) { - const [ entry, options ] = data - - this.addMenuItem(entry, options) - this.removeClass('vjs-hidden') - } - - onUserInactive () { - if (!this.dialog.hasClass('vjs-hidden')) { - this.hideDialog() - } - } - - bindEvents () { - document.addEventListener('click', this.documentClickHandler) - if (this.isInIframe()) { - window.addEventListener('blur', this.documentClickHandler) - } - - this.player().on('addsettingsitem', this.addSettingsItemHandler) - this.player().on('disposesettingsitem', this.disposeSettingsItemHandler) - this.player().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.player().peertube().onMenuOpened(); - - (this.menu.el() as HTMLElement).style.opacity = '1' - - this.dialog.show() - this.el().setAttribute('aria-expanded', 'true') - - this.setDialogSize(this.getComponentSize(this.menu)) - - const firstChild = this.menu.children()[0] - if (firstChild) firstChild.focus() - } - - hideDialog () { - this.player_.peertube().onMenuClosed() - - this.dialog.hide() - this.el().setAttribute('aria-expanded', 'false') - - this.setDialogSize(this.getComponentSize(this.menu)); - (this.menu.el() as HTMLElement).style.opacity = '1' - this.resetChildren() - } - - getComponentSize (element: videojs.Component | HTMLElement) { - let width: number = null - let height: number = null - - // Could be component or just DOM element - if (element instanceof Component) { - const el = element.el() as HTMLElement - - width = el.offsetWidth - height = el.offsetHeight; - - (element as any).width = width; - (element as any).height = height - } else { - width = element.offsetWidth - height = element.offsetHeight - } - - return [ width, height ] - } - - setDialogSize ([ width, height ]: number[]) { - if (typeof height !== 'number') { - return - } - - const offset = this.settingsButtonOptions.setup.maxHeightOffset - const maxHeight = (this.player().el() as HTMLElement).offsetHeight - offset - - const panelEl = this.panel.el() as HTMLElement - - if (height > maxHeight) { - height = maxHeight - width += 17 - panelEl.style.maxHeight = `${height}px` - } else if (panelEl.style.maxHeight !== '') { - panelEl.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') - const entries = this.settingsButtonOptions.entries - - if (entries.length === 0) { - this.addClass('vjs-hidden') - this.panelChild.addChild(this.menu) - return - } - - for (const entry of entries) { - this.addMenuItem(entry, this.settingsButtonOptions) - } - - this.panelChild.addChild(this.menu) - } - - addMenuItem (entry: any, options: any) { - const openSubMenu = function (this: any) { - if (videojs.dom.hasClass(this.el_, 'open')) { - videojs.dom.removeClass(this.el_, 'open') - } else { - videojs.dom.addClass(this.el_, 'open') - } - } - - options.name = toTitleCase(entry) - - const newOptions = Object.assign({}, options, { entry, menuButton: this }) - const settingsMenuItem = new SettingsMenuItem(this.player(), newOptions) - - 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 (const menuChild of this.menu.children()) { - (menuChild as SettingsMenuItem).reset() - } - } - - /** - * Hide all the sub menus - */ - hideChildren () { - for (const menuChild of this.menu.children()) { - (menuChild as SettingsMenuItem).hideSubMenu() - } - } - - isInIframe () { - return window.self !== window.top - } - -} - -Component.registerComponent('SettingsButton', SettingsButton) - -export { SettingsButton } diff --git a/client/src/assets/player/settings/settings-menu-item.ts b/client/src/assets/player/settings/settings-menu-item.ts deleted file mode 100644 index 31d42c456..000000000 --- a/client/src/assets/player/settings/settings-menu-item.ts +++ /dev/null @@ -1,378 +0,0 @@ -import videojs from 'video.js' -// Thanks to Yanko Shterev: https://github.com/yshterev/videojs-settings-menu -import { toTitleCase } from '../utils' -import { SettingsDialog } from './settings-dialog' -import { SettingsButton } from './settings-menu-button' -import { SettingsPanel } from './settings-panel' -import { SettingsPanelChild } from './settings-panel-child' - -const MenuItem = videojs.getComponent('MenuItem') -const component = videojs.getComponent('Component') - -export interface SettingsMenuItemOptions extends videojs.MenuItemOptions { - entry: string - menuButton: SettingsButton -} - -class SettingsMenuItem extends MenuItem { - settingsButton: SettingsButton - dialog: SettingsDialog - mainMenu: videojs.Menu - panel: SettingsPanel - panelChild: SettingsPanelChild - panelChildEl: HTMLElement - size: number[] - menuToLoad: string - subMenu: SettingsButton - - submenuClickHandler: typeof SettingsMenuItem.prototype.onSubmenuClick - transitionEndHandler: typeof SettingsMenuItem.prototype.onTransitionEnd - - settingsSubMenuTitleEl_: HTMLElement - settingsSubMenuValueEl_: HTMLElement - settingsSubMenuEl_: HTMLElement - - constructor (player: videojs.Player, options?: SettingsMenuItemOptions) { - super(player, options) - - this.settingsButton = options.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() as HTMLElement - - this.size = null - - // keep state of what menu type is loading next - this.menuToLoad = 'mainmenu' - - const subMenuName = toTitleCase(options.entry) - const SubMenuComponent = videojs.getComponent(subMenuName) - - if (!SubMenuComponent) { - throw new Error(`Component ${subMenuName} does not exist`) - } - - const newOptions = Object.assign({}, options, { entry: options.menuButton, menuButton: this }) - - this.subMenu = new SubMenuComponent(this.player(), newOptions) as SettingsButton - const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0] - this.settingsSubMenuEl_.className += ' ' + subMenuClass - - this.eventHandlers() - - player.ready(() => { - // Voodoo magic for IOS - setTimeout(() => { - // Player was destroyed - if (!this.player_) return - - this.build() - - // Update on rate change - player.on('ratechange', this.submenuClickHandler) - - if (subMenuName === 'CaptionsButton') { - // Hack to regenerate captions on HTTP fallback - player.on('captionsChanged', () => { - setTimeout(() => { - this.settingsSubMenuEl_.innerHTML = '' - this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) - this.update() - this.bindClickEvents() - }, 0) - }) - } - - this.reset() - }, 0) - }) - } - - eventHandlers () { - this.submenuClickHandler = this.onSubmenuClick.bind(this) - this.transitionEndHandler = this.onTransitionEnd.bind(this) - } - - onSubmenuClick (event: any) { - let target = null - - if (event.type === 'tap') { - target = event.target - } else { - target = event.currentTarget || event.target - } - - 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) - - // Seems like videojs adds a vjs-hidden class on the caption menu after a click - // We don't need it - this.subMenu.menu.removeClass('vjs-hidden') - } - - /** - * Create the component's DOM element - * - */ - createEl () { - const el = videojs.dom.createEl('li', { - className: 'vjs-menu-item', - tabIndex: -1 - }) - - this.settingsSubMenuTitleEl_ = videojs.dom.createEl('div', { - className: 'vjs-settings-sub-menu-title' - }) as HTMLElement - - el.appendChild(this.settingsSubMenuTitleEl_) - - this.settingsSubMenuValueEl_ = videojs.dom.createEl('div', { - className: 'vjs-settings-sub-menu-value' - }) as HTMLElement - - el.appendChild(this.settingsSubMenuValueEl_) - - this.settingsSubMenuEl_ = videojs.dom.createEl('div', { - className: 'vjs-settings-sub-menu' - }) as HTMLElement - - return el as HTMLLIElement - } - - /** - * Handle click on menu item - * - * @method handleClick - */ - handleClick (event: videojs.EventTarget.Event) { - this.menuToLoad = 'submenu' - // Remove open class to ensure only the open submenu gets this class - videojs.dom.removeClass(this.el(), 'open') - - super.handleClick(event); - - (this.mainMenu.el() as HTMLElement).style.opacity = '0' - // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element - if (videojs.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { - videojs.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) - - const firstChild = this.subMenu.menu.children()[0] - if (firstChild) firstChild.focus() - } else { - videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - } - } - - /** - * Create back button - * - * @method createBackButton - */ - createBackButton () { - const button = this.subMenu.menu.addChild('MenuItem', {}, 0) - - button.addClass('vjs-back-button'); - (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) - } - - /** - * Add/remove prefixed event listener for CSS Transition - * - * @method PrefixedEvent - */ - PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { - const 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: any) { - if (event.propertyName !== 'margin-right') { - return - } - - if (this.menuToLoad === 'mainmenu') { - // hide submenu - videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - - // reset opacity to 0 - this.settingsSubMenuEl_.style.opacity = '0' - } - } - - reset () { - videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - this.settingsSubMenuEl_.style.opacity = '0' - this.setMargin() - } - - loadMainMenu () { - const mainMenuEl = this.mainMenu.el() as HTMLElement - this.menuToLoad = 'mainmenu' - this.mainMenu.show() - mainMenuEl.style.opacity = '0' - - // back button will always take you to main menu, so set dialog sizes - const mainMenuAny = this.mainMenu as any - this.settingsButton.setDialogSize([ mainMenuAny.width, mainMenuAny.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() - mainMenuEl.style.opacity = '1' - - const firstChild = this.mainMenu.children()[0] - if (firstChild) firstChild.focus() - }, 0) - } - - build () { - this.subMenu.on('labelUpdated', () => { - this.update() - }) - this.subMenu.on('menuChanged', () => { - this.bindClickEvents() - this.setSize() - this.update() - }) - - this.settingsSubMenuTitleEl_.innerHTML = this.player().localize(this.subMenu.controlText()) - this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) - this.panelChildEl.appendChild(this.settingsSubMenuEl_) - this.update() - - this.createBackButton() - this.setSize() - this.bindClickEvents() - - // prefixed event listeners for CSS TransitionEnd - this.PrefixedEvent( - this.settingsSubMenuEl_, - 'TransitionEnd', - this.transitionEndHandler, - 'addEvent' - ) - } - - update (event?: any) { - let target: HTMLElement = null - const 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') { - const html = (this.subMenu as any).labelEl_.innerHTML - - setTimeout(() => { - this.settingsSubMenuValueEl_.innerHTML = html - }, 250) - } else { - // Loop trough the submenu items to find the selected child - for (const subMenuItem of this.subMenu.menu.children_) { - if (!(subMenuItem instanceof component)) { - continue - } - - if (subMenuItem.hasClass('vjs-selected')) { - const subMenuItemUntyped = subMenuItem as any - - // Prefer to use the function - if (typeof subMenuItemUntyped.getLabel === 'function') { - this.settingsSubMenuValueEl_.innerHTML = subMenuItemUntyped.getLabel() - break - } - - this.settingsSubMenuValueEl_.innerHTML = this.player().localize(subMenuItemUntyped.options_.label) - } - } - } - - if (target && !target.classList.contains('vjs-back-button')) { - this.settingsButton.hideDialog() - } - } - - bindClickEvents () { - for (const 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 - setSize () { - this.dialog.removeClass('vjs-hidden') - videojs.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') - this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) - this.setMargin() - this.dialog.addClass('vjs-hidden') - videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - } - - setMargin () { - if (!this.size) return - - const [ 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 (videojs.dom.hasClass(this.el(), 'open')) { - videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') - videojs.dom.removeClass(this.el(), 'open') - } - } - -} - -(SettingsMenuItem as any).prototype.contentElType = 'button' -videojs.registerComponent('SettingsMenuItem', SettingsMenuItem) - -export { SettingsMenuItem } diff --git a/client/src/assets/player/settings/settings-panel-child.ts b/client/src/assets/player/settings/settings-panel-child.ts deleted file mode 100644 index 161420c38..000000000 --- a/client/src/assets/player/settings/settings-panel-child.ts +++ /dev/null @@ -1,18 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') - -class SettingsPanelChild extends Component { - - createEl () { - return super.createEl('div', { - className: 'vjs-settings-panel-child', - innerHTML: '', - tabIndex: -1 - }) - } -} - -Component.registerComponent('SettingsPanelChild', SettingsPanelChild) - -export { SettingsPanelChild } diff --git a/client/src/assets/player/settings/settings-panel.ts b/client/src/assets/player/settings/settings-panel.ts deleted file mode 100644 index 28b579bdd..000000000 --- a/client/src/assets/player/settings/settings-panel.ts +++ /dev/null @@ -1,18 +0,0 @@ -import videojs from 'video.js' - -const Component = videojs.getComponent('Component') - -class SettingsPanel extends Component { - - createEl () { - return super.createEl('div', { - className: 'vjs-settings-panel', - innerHTML: '', - tabIndex: -1 - }) - } -} - -Component.registerComponent('SettingsPanel', SettingsPanel) - -export { SettingsPanel } diff --git a/client/src/assets/player/shared/bezels/bezels-plugin.ts b/client/src/assets/player/shared/bezels/bezels-plugin.ts new file mode 100644 index 000000000..ca88bc1f9 --- /dev/null +++ b/client/src/assets/player/shared/bezels/bezels-plugin.ts @@ -0,0 +1,21 @@ +import videojs from 'video.js' +import './pause-bezel' + +const Plugin = videojs.getPlugin('plugin') + +class BezelsPlugin extends Plugin { + + constructor (player: videojs.Player, options?: videojs.ComponentOptions) { + super(player) + + this.player.ready(() => { + player.addClass('vjs-bezels') + }) + + player.addChild('PauseBezel', options) + } +} + +videojs.registerPlugin('bezels', BezelsPlugin) + +export { BezelsPlugin } diff --git a/client/src/assets/player/shared/bezels/index.ts b/client/src/assets/player/shared/bezels/index.ts new file mode 100644 index 000000000..da861b07a --- /dev/null +++ b/client/src/assets/player/shared/bezels/index.ts @@ -0,0 +1,2 @@ +export * from './bezels-plugin' +export * from './pause-bezel' diff --git a/client/src/assets/player/shared/bezels/pause-bezel.ts b/client/src/assets/player/shared/bezels/pause-bezel.ts new file mode 100644 index 000000000..e35c39a5f --- /dev/null +++ b/client/src/assets/player/shared/bezels/pause-bezel.ts @@ -0,0 +1,76 @@ +import videojs from 'video.js' +import { isMobile } from '@root-helpers/web-browser' + +function getPauseBezel () { + return ` +
+
+
+ + + + +
+
+
+ ` +} + +function getPlayBezel () { + return ` +
+
+
+ + + + +
+
+
+ ` +} + +const Component = videojs.getComponent('Component') +class PauseBezel extends Component { + container: HTMLDivElement + + constructor (player: videojs.Player, options?: videojs.ComponentOptions) { + super(player, options) + + // Hide bezels on mobile since we already have our mobile overlay + if (isMobile()) return + + player.on('pause', (_: any) => { + if (player.seeking() || player.ended()) return + this.container.innerHTML = getPauseBezel() + this.showBezel() + }) + + player.on('play', (_: any) => { + if (player.seeking()) return + this.container.innerHTML = getPlayBezel() + this.showBezel() + }) + } + + createEl () { + this.container = super.createEl('div', { + className: 'vjs-bezels-content' + }) as HTMLDivElement + + this.container.style.display = 'none' + + return this.container + } + + showBezel () { + this.container.style.display = 'inherit' + + setTimeout(() => { + this.container.style.display = 'none' + }, 500) // matching the animation duration + } +} + +videojs.registerComponent('PauseBezel', PauseBezel) diff --git a/client/src/assets/player/shared/common/index.ts b/client/src/assets/player/shared/common/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/client/src/assets/player/shared/common/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/client/src/assets/player/shared/common/utils.ts b/client/src/assets/player/shared/common/utils.ts new file mode 100644 index 000000000..da7dda0c7 --- /dev/null +++ b/client/src/assets/player/shared/common/utils.ts @@ -0,0 +1,66 @@ +import { VideoFile } from '@shared/models' + +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: number) { + 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 videoFileMaxByResolution (files: VideoFile[]) { + let max = files[0] + + for (let i = 1; i < files.length; i++) { + const file = files[i] + if (max.resolution.id < file.resolution.id) max = file + } + + return max +} + +function videoFileMinByResolution (files: VideoFile[]) { + let min = files[0] + + for (let i = 1; i < files.length; i++) { + const file = files[i] + if (min.resolution.id > file.resolution.id) min = file + } + + return min +} + +function getRtcConfig () { + return { + iceServers: [ + { + urls: 'stun:stun.stunprotocol.org' + }, + { + urls: 'stun:stun.framasoft.org' + } + ] + } +} + +// --------------------------------------------------------------------------- + +export { + getRtcConfig, + toTitleCase, + + videoFileMaxByResolution, + videoFileMinByResolution, + bytes +} diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts new file mode 100644 index 000000000..db5b8938d --- /dev/null +++ b/client/src/assets/player/shared/control-bar/index.ts @@ -0,0 +1,5 @@ +export * from './next-previous-video-button' +export * from './p2p-info-button' +export * from './peertube-link-button' +export * from './peertube-load-progress-bar' +export * from './theater-button' diff --git a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts new file mode 100644 index 000000000..b7b986806 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts @@ -0,0 +1,50 @@ +import videojs from 'video.js' +import { NextPreviousVideoButtonOptions } from '../../types' + +const Button = videojs.getComponent('Button') + +class NextPreviousVideoButton extends Button { + private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions + + constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { + super(player, options as any) + + this.nextPreviousVideoButtonOptions = options + + this.update() + } + + createEl () { + const type = (this.options_ as NextPreviousVideoButtonOptions).type + + const button = videojs.dom.createEl('button', { + className: 'vjs-' + type + '-video' + }) as HTMLButtonElement + const nextIcon = videojs.dom.createEl('span', { + className: 'icon icon-' + type + }) + button.appendChild(nextIcon) + + if (type === 'next') { + button.title = this.player_.localize('Next video') + } else { + button.title = this.player_.localize('Previous video') + } + + return button + } + + handleClick () { + this.nextPreviousVideoButtonOptions.handler() + } + + update () { + const disabled = this.nextPreviousVideoButtonOptions.isDisabled() + + if (disabled) this.addClass('vjs-disabled') + else this.removeClass('vjs-disabled') + } +} + +videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) +videojs.registerComponent('PreviousVideoButton', NextPreviousVideoButton) diff --git a/client/src/assets/player/shared/control-bar/p2p-info-button.ts b/client/src/assets/player/shared/control-bar/p2p-info-button.ts new file mode 100644 index 000000000..36517e125 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/p2p-info-button.ts @@ -0,0 +1,124 @@ +import videojs from 'video.js' +import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' +import { bytes } from '../common' + +const Button = videojs.getComponent('Button') +class P2pInfoButton extends Button { + + constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) { + super(player, options as any) + } + + createEl () { + const div = videojs.dom.createEl('div', { + className: 'vjs-peertube' + }) + const subDivWebtorrent = videojs.dom.createEl('div', { + className: 'vjs-peertube-hidden' // Hide the stats before we get the info + }) as HTMLDivElement + div.appendChild(subDivWebtorrent) + + // Stop here if P2P is not enabled + const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled + if (!p2pEnabled) return div as HTMLButtonElement + + const downloadIcon = videojs.dom.createEl('span', { + className: 'icon icon-download' + }) + subDivWebtorrent.appendChild(downloadIcon) + + const downloadSpeedText = videojs.dom.createEl('span', { + className: 'download-speed-text' + }) + const downloadSpeedNumber = videojs.dom.createEl('span', { + className: 'download-speed-number' + }) + const downloadSpeedUnit = videojs.dom.createEl('span') + downloadSpeedText.appendChild(downloadSpeedNumber) + downloadSpeedText.appendChild(downloadSpeedUnit) + subDivWebtorrent.appendChild(downloadSpeedText) + + const uploadIcon = videojs.dom.createEl('span', { + className: 'icon icon-upload' + }) + subDivWebtorrent.appendChild(uploadIcon) + + const uploadSpeedText = videojs.dom.createEl('span', { + className: 'upload-speed-text' + }) + const uploadSpeedNumber = videojs.dom.createEl('span', { + className: 'upload-speed-number' + }) + const uploadSpeedUnit = videojs.dom.createEl('span') + uploadSpeedText.appendChild(uploadSpeedNumber) + uploadSpeedText.appendChild(uploadSpeedUnit) + subDivWebtorrent.appendChild(uploadSpeedText) + + const peersText = videojs.dom.createEl('span', { + className: 'peers-text' + }) + const peersNumber = videojs.dom.createEl('span', { + className: 'peers-number' + }) + subDivWebtorrent.appendChild(peersNumber) + subDivWebtorrent.appendChild(peersText) + + const subDivHttp = videojs.dom.createEl('div', { + className: 'vjs-peertube-hidden' + }) + const subDivHttpText = videojs.dom.createEl('span', { + className: 'http-fallback', + textContent: 'HTTP' + }) + + subDivHttp.appendChild(subDivHttpText) + div.appendChild(subDivHttp) + + this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { + // We are in HTTP fallback + if (!data) { + subDivHttp.className = 'vjs-peertube-displayed' + subDivWebtorrent.className = 'vjs-peertube-hidden' + + return + } + + const p2pStats = data.p2p + const httpStats = data.http + + const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed) + const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed) + const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded) + const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded) + const numPeers = p2pStats.numPeers + + subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + + if (data.source === 'p2p-media-loader') { + const downloadedFromServer = bytes(httpStats.downloaded).join(' ') + const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') + + subDivWebtorrent.title += + ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + + ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' + } + subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') + + downloadSpeedNumber.textContent = downloadSpeed[0] + downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] + + uploadSpeedNumber.textContent = uploadSpeed[0] + uploadSpeedUnit.textContent = ' ' + uploadSpeed[1] + + peersNumber.textContent = numPeers.toString() + peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) + + subDivHttp.className = 'vjs-peertube-hidden' + subDivWebtorrent.className = 'vjs-peertube-displayed' + }) + + return div as HTMLButtonElement + } +} + +videojs.registerComponent('P2PInfoButton', P2pInfoButton) diff --git a/client/src/assets/player/shared/control-bar/peertube-link-button.ts b/client/src/assets/player/shared/control-bar/peertube-link-button.ts new file mode 100644 index 000000000..6d83263cc --- /dev/null +++ b/client/src/assets/player/shared/control-bar/peertube-link-button.ts @@ -0,0 +1,45 @@ +import videojs from 'video.js' +import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' +import { PeerTubeLinkButtonOptions } from '../../types' + +const Button = videojs.getComponent('Button') +class PeerTubeLinkButton extends Button { + + constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { + super(player, options as any) + } + + createEl () { + return this.buildElement() + } + + updateHref () { + this.el().setAttribute('href', this.buildLink()) + } + + handleClick () { + this.player().pause() + } + + private buildElement () { + const el = videojs.dom.createEl('a', { + href: this.buildLink(), + innerHTML: 'PeerTube', + title: this.player().localize('Video page (new window)'), + className: 'vjs-peertube-link', + target: '_blank' + }) + + el.addEventListener('mouseenter', () => this.updateHref()) + + return el as HTMLButtonElement + } + + private buildLink () { + const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID }) + + return decorateVideoLink({ url, startTime: this.player().currentTime() }) + } +} + +videojs.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) diff --git a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts new file mode 100644 index 000000000..623e70eb2 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts @@ -0,0 +1,33 @@ +import videojs from 'video.js' + +const Component = videojs.getComponent('Component') + +class PeerTubeLoadProgressBar extends Component { + + constructor (player: videojs.Player, options?: videojs.ComponentOptions) { + super(player, options) + + this.on(player, 'progress', this.update) + } + + createEl () { + return super.createEl('div', { + className: 'vjs-load-progress', + innerHTML: `${this.localize('Loaded')}: 0%` + }) + } + + dispose () { + super.dispose() + } + + update () { + const torrent = this.player().webtorrent().getTorrent() + if (!torrent) return + + (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%' + } + +} + +Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) diff --git a/client/src/assets/player/shared/control-bar/theater-button.ts b/client/src/assets/player/shared/control-bar/theater-button.ts new file mode 100644 index 000000000..56c349d6b --- /dev/null +++ b/client/src/assets/player/shared/control-bar/theater-button.ts @@ -0,0 +1,53 @@ +import videojs from 'video.js' +import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' + +const Button = videojs.getComponent('Button') +class TheaterButton extends Button { + + private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' + + constructor (player: videojs.Player, options: videojs.ComponentOptions) { + super(player, options) + + const enabled = getStoredTheater() + if (enabled === true) { + this.player().addClass(TheaterButton.THEATER_MODE_CLASS) + + this.handleTheaterChange() + } + + this.controlText('Theater mode') + + this.player().theaterEnabled = enabled + } + + buildCSSClass () { + return `vjs-theater-control ${super.buildCSSClass()}` + } + + handleTheaterChange () { + const theaterEnabled = this.isTheaterEnabled() + + if (theaterEnabled) { + this.controlText('Normal mode') + } else { + this.controlText('Theater mode') + } + + saveTheaterInStore(theaterEnabled) + + this.player_.trigger('theaterChange', theaterEnabled) + } + + handleClick () { + this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS) + + this.handleTheaterChange() + } + + private isTheaterEnabled () { + return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) + } +} + +videojs.registerComponent('TheaterButton', TheaterButton) diff --git a/client/src/assets/player/shared/dock/index.ts b/client/src/assets/player/shared/dock/index.ts new file mode 100644 index 000000000..16e70a9c1 --- /dev/null +++ b/client/src/assets/player/shared/dock/index.ts @@ -0,0 +1,2 @@ +export * from './peertube-dock-component' +export * from './peertube-dock-plugin' diff --git a/client/src/assets/player/shared/dock/peertube-dock-component.ts b/client/src/assets/player/shared/dock/peertube-dock-component.ts new file mode 100644 index 000000000..183c7a00f --- /dev/null +++ b/client/src/assets/player/shared/dock/peertube-dock-component.ts @@ -0,0 +1,65 @@ +import videojs from 'video.js' + +const Component = videojs.getComponent('Component') + +export type PeerTubeDockComponentOptions = { + title?: string + description?: string + avatarUrl?: string +} + +class PeerTubeDockComponent extends Component { + + createEl () { + const options = this.options_ as PeerTubeDockComponentOptions + + const el = super.createEl('div', { + className: 'peertube-dock' + }) + + if (options.avatarUrl) { + const avatar = videojs.dom.createEl('img', { + className: 'peertube-dock-avatar', + src: options.avatarUrl + }) + + el.appendChild(avatar) + } + + const elWrapperTitleDescription = super.createEl('div', { + className: 'peertube-dock-title-description' + }) + + if (options.title) { + const title = videojs.dom.createEl('div', { + className: 'peertube-dock-title', + title: options.title, + innerHTML: options.title + }) + + elWrapperTitleDescription.appendChild(title) + } + + if (options.description) { + const description = videojs.dom.createEl('div', { + className: 'peertube-dock-description', + title: options.description, + innerHTML: options.description + }) + + elWrapperTitleDescription.appendChild(description) + } + + if (options.title || options.description) { + el.appendChild(elWrapperTitleDescription) + } + + return el + } +} + +videojs.registerComponent('PeerTubeDockComponent', PeerTubeDockComponent) + +export { + PeerTubeDockComponent +} diff --git a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts new file mode 100644 index 000000000..245981692 --- /dev/null +++ b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts @@ -0,0 +1,25 @@ +import videojs from 'video.js' +import { PeerTubeDockComponent } from './peertube-dock-component' + +const Plugin = videojs.getPlugin('plugin') + +export type PeerTubeDockPluginOptions = { + title?: string + description?: string + avatarUrl?: string +} + +class PeerTubeDockPlugin extends Plugin { + constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { + super(player, options) + + this.player.addClass('peertube-dock') + + this.player.ready(() => { + this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent + }) + } +} + +videojs.registerPlugin('peertubeDock', PeerTubeDockPlugin) +export { PeerTubeDockPlugin } diff --git a/client/src/assets/player/shared/hotkeys/index.ts b/client/src/assets/player/shared/hotkeys/index.ts new file mode 100644 index 000000000..cc99a1ea8 --- /dev/null +++ b/client/src/assets/player/shared/hotkeys/index.ts @@ -0,0 +1 @@ +export * from './peertube-hotkeys-plugin' diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts new file mode 100644 index 000000000..5920450bd --- /dev/null +++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts @@ -0,0 +1,196 @@ +import videojs from 'video.js' + +type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardEvent) => void } + +const Plugin = videojs.getPlugin('plugin') + +class PeerTubeHotkeysPlugin extends Plugin { + private static readonly VOLUME_STEP = 0.1 + private static readonly SEEK_STEP = 5 + + private readonly handleKeyFunction: (event: KeyboardEvent) => void + + private readonly handlers: KeyHandler[] + + constructor (player: videojs.Player, options: videojs.PlayerOptions) { + super(player, options) + + this.handlers = this.buildHandlers() + + this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event) + document.addEventListener('keydown', this.handleKeyFunction) + } + + dispose () { + document.removeEventListener('keydown', this.handleKeyFunction) + } + + private onKeyDown (event: KeyboardEvent) { + if (!this.isValidKeyTarget(event.target as HTMLElement)) return + + for (const handler of this.handlers) { + if (handler.accept(event)) { + handler.cb(event) + return + } + } + } + + private buildHandlers () { + const handlers: KeyHandler[] = [ + // Play + { + accept: e => (e.key === ' ' || e.key === 'MediaPlayPause'), + cb: e => { + e.preventDefault() + e.stopPropagation() + + if (this.player.paused()) this.player.play() + else this.player.pause() + } + }, + + // Increase volume + { + accept: e => this.isNaked(e, 'ArrowUp'), + cb: e => { + e.preventDefault() + this.player.volume(this.player.volume() + PeerTubeHotkeysPlugin.VOLUME_STEP) + } + }, + + // Decrease volume + { + accept: e => this.isNaked(e, 'ArrowDown'), + cb: e => { + e.preventDefault() + this.player.volume(this.player.volume() - PeerTubeHotkeysPlugin.VOLUME_STEP) + } + }, + + // Rewind + { + accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'), + cb: e => { + e.preventDefault() + + const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP) + this.player.currentTime(target) + } + }, + + // Forward + { + accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'), + cb: e => { + e.preventDefault() + + const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP) + this.player.currentTime(target) + } + }, + + // Fullscreen + { + // f key or Ctrl + Enter + accept: e => this.isNaked(e, 'f') || (!e.altKey && e.ctrlKey && e.key === 'Enter'), + cb: e => { + e.preventDefault() + + if (this.player.isFullscreen()) this.player.exitFullscreen() + else this.player.requestFullscreen() + } + }, + + // Mute + { + accept: e => this.isNaked(e, 'm'), + cb: e => { + e.preventDefault() + + this.player.muted(!this.player.muted()) + } + }, + + // Increase playback rate + { + accept: e => e.key === '>', + cb: () => { + const target = Math.min(this.player.playbackRate() + 0.1, 5) + + this.player.playbackRate(parseFloat(target.toFixed(2))) + } + }, + + // Decrease playback rate + { + accept: e => e.key === '<', + cb: () => { + const target = Math.max(this.player.playbackRate() - 0.1, 0.10) + + this.player.playbackRate(parseFloat(target.toFixed(2))) + } + }, + + // Previous frame + { + accept: e => e.key === ',', + cb: () => { + this.player.pause() + + // Calculate movement distance (assuming 30 fps) + const dist = 1 / 30 + this.player.currentTime(this.player.currentTime() - dist) + } + }, + + // Next frame + { + accept: e => e.key === '.', + cb: () => { + this.player.pause() + + // Calculate movement distance (assuming 30 fps) + const dist = 1 / 30 + this.player.currentTime(this.player.currentTime() + dist) + } + } + ] + + // 0-9 key handlers + for (let i = 0; i < 10; i++) { + handlers.push({ + accept: e => e.key === i + '', + cb: e => { + e.preventDefault() + + this.player.currentTime(this.player.duration() * i * 0.1) + } + }) + } + + return handlers + } + + private isValidKeyTarget (eventEl: HTMLElement) { + const playerEl = this.player.el() + const activeEl = document.activeElement + const currentElTagName = eventEl.tagName.toLowerCase() + + return ( + activeEl === playerEl || + activeEl === playerEl.querySelector('.vjs-tech') || + activeEl === playerEl.querySelector('.vjs-control-bar') || + eventEl.id === 'content' || + currentElTagName === 'body' || + currentElTagName === 'video' + ) + } + + private isNaked (event: KeyboardEvent, key: string) { + return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === key) + } +} + +videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin) +export { PeerTubeHotkeysPlugin } diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts new file mode 100644 index 000000000..72a10eb26 --- /dev/null +++ b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts @@ -0,0 +1,137 @@ +import { + CommonOptions, + NextPreviousVideoButtonOptions, + PeerTubeLinkButtonOptions, + PeertubePlayerManagerOptions, + PlayerMode +} from '../../types' + +export class ControlBarOptionsBuilder { + private options: CommonOptions + + constructor ( + globalOptions: PeertubePlayerManagerOptions, + private mode: PlayerMode + ) { + this.options = globalOptions.common + } + + getChildrenOptions () { + const children = {} + + if (this.options.previousVideo) { + Object.assign(children, this.getPreviousVideo()) + } + + Object.assign(children, { playToggle: {} }) + + if (this.options.nextVideo) { + Object.assign(children, this.getNextVideo()) + } + + Object.assign(children, { + currentTimeDisplay: {}, + timeDivider: {}, + durationDisplay: {}, + liveDisplay: {}, + + flexibleWidthSpacer: {}, + + ...this.getProgressControl(), + + p2PInfoButton: { + p2pEnabled: this.options.p2pEnabled + }, + + muteToggle: {}, + volumeControl: {}, + + ...this.getSettingsButton() + }) + + if (this.options.peertubeLink === true) { + Object.assign(children, { + peerTubeLinkButton: { shortUUID: this.options.videoShortUUID } as PeerTubeLinkButtonOptions + }) + } + + if (this.options.theaterButton === true) { + Object.assign(children, { + theaterButton: {} + }) + } + + Object.assign(children, { + fullscreenToggle: {} + }) + + return children + } + + private getSettingsButton () { + const settingEntries: string[] = [] + + settingEntries.push('playbackRateMenuButton') + + if (this.options.captions === true) settingEntries.push('captionsButton') + + settingEntries.push('resolutionMenuButton') + + return { + settingsButton: { + setup: { + maxHeightOffset: 40 + }, + entries: settingEntries + } + } + } + + private getProgressControl () { + const loadProgressBar = this.mode === 'webtorrent' + ? 'peerTubeLoadProgressBar' + : 'loadProgressBar' + + return { + progressControl: { + children: { + seekBar: { + children: { + [loadProgressBar]: {}, + mouseTimeDisplay: {}, + playProgressBar: {} + } + } + } + } + } + } + + private getPreviousVideo () { + const buttonOptions: NextPreviousVideoButtonOptions = { + type: 'previous', + handler: this.options.previousVideo, + isDisabled: () => { + if (!this.options.hasPreviousVideo) return false + + return !this.options.hasPreviousVideo() + } + } + + return { previousVideoButton: buttonOptions } + } + + private getNextVideo () { + const buttonOptions: NextPreviousVideoButtonOptions = { + type: 'next', + handler: this.options.nextVideo, + isDisabled: () => { + if (!this.options.hasNextVideo) return false + + return !this.options.hasNextVideo() + } + } + + return { nextVideoButton: buttonOptions } + } +} diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/manager-options/hls-options-builder.ts new file mode 100644 index 000000000..e7f664fd4 --- /dev/null +++ b/client/src/assets/player/shared/manager-options/hls-options-builder.ts @@ -0,0 +1,192 @@ +import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core' +import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' +import { LiveVideoLatencyMode } from '@shared/models' +import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' +import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' +import { PeertubePlayerManagerOptions } from '../../types/manager-options' +import { getRtcConfig } from '../common' +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' + +export class HLSOptionsBuilder { + + constructor ( + private options: PeertubePlayerManagerOptions, + private p2pMediaLoaderModule?: any + ) { + + } + + getPluginOptions () { + const commonOptions = this.options.common + + const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) + + const p2pMediaLoaderConfig = this.getP2PMediaLoaderOptions(redundancyUrlManager) + const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader + + const p2pMediaLoader: P2PMediaLoaderPluginOptions = { + redundancyUrlManager, + type: 'application/x-mpegURL', + startTime: commonOptions.startTime, + src: this.options.p2pMediaLoader.playlistUrl, + loader + } + + const hlsjs = { + levelLabelHandler: (level: { height: number, width: number }) => { + const resolution = Math.min(level.height || 0, level.width || 0) + + const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution) + // We don't have files for live videos + if (!file) return level.height + + let label = file.resolution.label + if (file.fps >= 50) label += file.fps + + return label + }, + html5: { + hlsjsConfig: this.getHLSJSOptions(loader) + } + } + + return { p2pMediaLoader, hlsjs } + } + + // --------------------------------------------------------------------------- + + private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { + let consumeOnly = false + if ((navigator as any)?.connection?.type === 'cellular') { + console.log('We are on a cellular connection: disabling seeding.') + consumeOnly = true + } + + const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce + .filter(t => t.startsWith('ws')) + + const specificLiveOrVODOptions = this.options.common.isLive + ? this.getP2PMediaLoaderLiveOptions() + : this.getP2PMediaLoaderVODOptions() + + return { + loader: { + + trackerAnnounce, + rtcConfig: getRtcConfig(), + + simultaneousHttpDownloads: 1, + httpFailedSegmentTimeout: 1000, + + segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive), + segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1), + + useP2P: this.options.common.p2pEnabled, + consumeOnly, + + ...specificLiveOrVODOptions + }, + segments: { + swarmId: this.options.p2pMediaLoader.playlistUrl, + forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority + } + } + } + + private getP2PMediaLoaderLiveOptions (): Partial { + const base = { + requiredSegmentsPriority: 1 + } + + const latencyMode = this.options.common.liveOptions.latencyMode + + switch (latencyMode) { + case LiveVideoLatencyMode.SMALL_LATENCY: + return { + ...base, + + useP2P: false, + httpDownloadProbability: 1 + } + + case LiveVideoLatencyMode.HIGH_LATENCY: + return base + + default: + return base + } + } + + private getP2PMediaLoaderVODOptions (): Partial { + return { + requiredSegmentsPriority: 3, + + cachedSegmentExpiration: 86400000, + cachedSegmentsCount: 100, + + httpDownloadMaxPriority: 9, + httpDownloadProbability: 0.06, + httpDownloadProbabilitySkipIfNoPeers: true, + + p2pDownloadMaxPriority: 50 + } + } + + // --------------------------------------------------------------------------- + + private getHLSJSOptions (loader: P2PMediaLoader) { + const specificLiveOrVODOptions = this.options.common.isLive + ? this.getHLSLiveOptions() + : this.getHLSVODOptions() + + const base = { + capLevelToPlayerSize: true, + autoStartLoad: false, + + loader, + + ...specificLiveOrVODOptions + } + + const averageBandwidth = getAverageBandwidthInStore() + if (!averageBandwidth) return base + + return { + ...base, + + abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s + startLevel: -1, + testBandwidth: false, + debug: false + } + } + + private getHLSLiveOptions () { + const latencyMode = this.options.common.liveOptions.latencyMode + + switch (latencyMode) { + case LiveVideoLatencyMode.SMALL_LATENCY: + return { + liveSyncDurationCount: 2 + } + + case LiveVideoLatencyMode.HIGH_LATENCY: + return { + liveSyncDurationCount: 10 + } + + default: + return { + liveSyncDurationCount: 5 + } + } + } + + private getHLSVODOptions () { + return { + liveSyncDurationCount: 5 + } + } +} diff --git a/client/src/assets/player/shared/manager-options/index.ts b/client/src/assets/player/shared/manager-options/index.ts new file mode 100644 index 000000000..4934d8302 --- /dev/null +++ b/client/src/assets/player/shared/manager-options/index.ts @@ -0,0 +1 @@ +export * from './manager-options-builder' diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts new file mode 100644 index 000000000..5dab1f7a9 --- /dev/null +++ b/client/src/assets/player/shared/manager-options/manager-options-builder.ts @@ -0,0 +1,169 @@ +import videojs from 'video.js' +import { copyToClipboard } from '@root-helpers/utils' +import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' +import { isIOS, isSafari } from '@root-helpers/web-browser' +import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' +import { isDefaultLocale } from '@shared/core-utils/i18n' +import { VideoJSPluginOptions } from '../../types' +import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options' +import { ControlBarOptionsBuilder } from './control-bar-options-builder' +import { HLSOptionsBuilder } from './hls-options-builder' +import { WebTorrentOptionsBuilder } from './webtorrent-options-builder' + +export class ManagerOptionsBuilder { + + constructor ( + private mode: PlayerMode, + private options: PeertubePlayerManagerOptions, + private p2pMediaLoaderModule?: any + ) { + + } + + getVideojsOptions (alreadyPlayed: boolean): videojs.PlayerOptions { + const commonOptions = this.options.common + + let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) + const html5 = { + preloadTextTracks: false + } + + const plugins: VideoJSPluginOptions = { + peertube: { + mode: this.mode, + autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent + videoViewUrl: commonOptions.videoViewUrl, + videoDuration: commonOptions.videoDuration, + userWatching: commonOptions.userWatching, + subtitle: commonOptions.subtitle, + videoCaptions: commonOptions.videoCaptions, + stopTime: commonOptions.stopTime, + isLive: commonOptions.isLive, + videoUUID: commonOptions.videoUUID + } + } + + if (commonOptions.playlist) { + plugins.playlist = commonOptions.playlist + } + + if (this.mode === 'p2p-media-loader') { + const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule) + + Object.assign(plugins, hlsOptionsBuilder.getPluginOptions()) + } else if (this.mode === 'webtorrent') { + const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed)) + + Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions()) + + // WebTorrent plugin handles autoplay, because we do some hackish stuff in there + autoplay = false + } + + const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode) + + const videojsOptions = { + html5, + + // We don't use text track settings for now + textTrackSettings: false as any, // FIXME: typings + controls: commonOptions.controls !== undefined ? commonOptions.controls : true, + loop: commonOptions.loop !== undefined ? commonOptions.loop : false, + + muted: commonOptions.muted !== undefined + ? commonOptions.muted + : undefined, // Undefined so the player knows it has to check the local storage + + autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed), + + poster: commonOptions.poster, + inactivityTimeout: commonOptions.inactivityTimeout, + playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], + + plugins, + + controlBar: { + children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings + } + } + + if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { + Object.assign(videojsOptions, { language: commonOptions.language }) + } + + return videojsOptions + } + + private getAutoPlayValue (autoplay: any, alreadyPlayed: boolean) { + if (autoplay !== true) return autoplay + + // On first play, disable autoplay to avoid issues + // But if the player already played videos, we can safely autoplay next ones + if (isIOS() || isSafari()) { + return alreadyPlayed ? 'play' : false + } + + return 'play' + } + + getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) { + const content = () => { + const isLoopEnabled = player.options_['loop'] + + const items = [ + { + icon: 'repeat', + label: player.localize('Play in loop') + (isLoopEnabled ? '' : ''), + listener: function () { + player.options_['loop'] = !isLoopEnabled + } + }, + { + label: player.localize('Copy the video URL'), + listener: function () { + copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID })) + } + }, + { + label: player.localize('Copy the video URL at the current time'), + listener: function (this: videojs.Player) { + const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID }) + + copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) + } + }, + { + icon: 'code', + label: player.localize('Copy embed code'), + listener: () => { + copyToClipboard(buildVideoOrPlaylistEmbed(commonOptions.embedUrl, commonOptions.embedTitle)) + } + } + ] + + if (this.mode === 'webtorrent') { + items.push({ + label: player.localize('Copy magnet URI'), + listener: function (this: videojs.Player) { + copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) + } + }) + } + + items.push({ + icon: 'info', + label: player.localize('Stats for nerds'), + listener: () => { + player.stats().show() + } + }) + + return items.map(i => ({ + ...i, + label: `` + i.label + })) + } + + return { content } + } +} diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts new file mode 100644 index 000000000..257cf1e05 --- /dev/null +++ b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts @@ -0,0 +1,36 @@ +import { PeertubePlayerManagerOptions } from '../../types' + +export class WebTorrentOptionsBuilder { + + constructor ( + private options: PeertubePlayerManagerOptions, + private autoPlayValue: any + ) { + + } + + getPluginOptions () { + const commonOptions = this.options.common + const webtorrentOptions = this.options.webtorrent + const p2pMediaLoaderOptions = this.options.p2pMediaLoader + + const autoplay = this.autoPlayValue === 'play' + + const webtorrent = { + autoplay, + + playerRefusedP2P: commonOptions.p2pEnabled === false, + videoDuration: commonOptions.videoDuration, + playerElement: commonOptions.playerElement, + + videoFiles: webtorrentOptions.videoFiles.length !== 0 + ? webtorrentOptions.videoFiles + // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode + : p2pMediaLoaderOptions?.videoFiles || [], + + startTime: commonOptions.startTime + } + + return { webtorrent } + } +} diff --git a/client/src/assets/player/shared/mobile/index.ts b/client/src/assets/player/shared/mobile/index.ts new file mode 100644 index 000000000..6f42b8db7 --- /dev/null +++ b/client/src/assets/player/shared/mobile/index.ts @@ -0,0 +1,2 @@ +export * from './peertube-mobile-buttons' +export * from './peertube-mobile-plugin' diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts new file mode 100644 index 000000000..09cb98f2e --- /dev/null +++ b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts @@ -0,0 +1,94 @@ +import videojs from 'video.js' + +const Component = videojs.getComponent('Component') +class PeerTubeMobileButtons extends Component { + + private rewind: Element + private forward: Element + private rewindText: Element + private forwardText: Element + + createEl () { + const container = super.createEl('div', { + className: 'vjs-mobile-buttons-overlay' + }) as HTMLDivElement + + const mainButton = super.createEl('div', { + className: 'main-button' + }) as HTMLDivElement + + mainButton.addEventListener('touchstart', e => { + e.stopPropagation() + + if (this.player_.paused() || this.player_.ended()) { + this.player_.play() + return + } + + this.player_.pause() + }) + + this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) + this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) + + for (let i = 0; i < 3; i++) { + this.rewind.appendChild(super.createEl('span', { className: 'icon' })) + this.forward.appendChild(super.createEl('span', { className: 'icon' })) + } + + this.rewindText = this.rewind.appendChild(super.createEl('div', { className: 'text' })) + this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) + + container.appendChild(this.rewind) + container.appendChild(mainButton) + container.appendChild(this.forward) + + return container + } + + displayFastSeek (amount: number) { + if (amount === 0) { + this.hideRewind() + this.hideForward() + return + } + + if (amount > 0) { + this.hideRewind() + this.displayForward(amount) + return + } + + if (amount < 0) { + this.hideForward() + this.displayRewind(amount) + return + } + } + + private hideRewind () { + this.rewind.classList.add('vjs-hidden') + this.rewindText.textContent = '' + } + + private displayRewind (amount: number) { + this.rewind.classList.remove('vjs-hidden') + this.rewindText.textContent = this.player().localize('{1} seconds', [ amount + '' ]) + } + + private hideForward () { + this.forward.classList.add('vjs-hidden') + this.forwardText.textContent = '' + } + + private displayForward (amount: number) { + this.forward.classList.remove('vjs-hidden') + this.forwardText.textContent = this.player().localize('{1} seconds', [ amount + '' ]) + } +} + +videojs.registerComponent('PeerTubeMobileButtons', PeerTubeMobileButtons) + +export { + PeerTubeMobileButtons +} diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts new file mode 100644 index 000000000..91dda7f94 --- /dev/null +++ b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts @@ -0,0 +1,155 @@ +import { PeerTubeMobileButtons } from './peertube-mobile-buttons' +import videojs from 'video.js' +import debug from 'debug' + +const logger = debug('peertube:player:mobile') + +const Plugin = videojs.getPlugin('plugin') + +class PeerTubeMobilePlugin extends Plugin { + private static readonly DOUBLE_TAP_DELAY_MS = 250 + private static readonly SET_CURRENT_TIME_DELAY = 1000 + + private peerTubeMobileButtons: PeerTubeMobileButtons + + private seekAmount = 0 + + private lastTapEvent: TouchEvent + private tapTimeout: ReturnType + private newActiveState: boolean + + private setCurrentTimeTimeout: ReturnType + + constructor (player: videojs.Player, options: videojs.PlayerOptions) { + super(player, options) + + this.peerTubeMobileButtons = player.addChild('PeerTubeMobileButtons', { reportTouchActivity: false }) as PeerTubeMobileButtons + + if (videojs.browser.IS_ANDROID && screen.orientation) { + this.handleFullscreenRotation() + } + + if (!this.player.options_.userActions) this.player.options_.userActions = {}; + + // FIXME: typings + (this.player.options_.userActions as any).click = false + this.player.options_.userActions.doubleClick = false + + this.player.one('play', () => { + this.initTouchStartEvents() + }) + } + + private handleFullscreenRotation () { + this.player.on('fullscreenchange', () => { + if (!this.player.isFullscreen() || this.isPortraitVideo()) return + + screen.orientation.lock('landscape') + .catch(err => console.error('Cannot lock screen to landscape.', err)) + }) + } + + private isPortraitVideo () { + return this.player.videoWidth() < this.player.videoHeight() + } + + private initTouchStartEvents () { + const handleTouchStart = (event: TouchEvent) => { + if (this.tapTimeout) { + clearTimeout(this.tapTimeout) + this.tapTimeout = undefined + } + + if (this.lastTapEvent && event.timeStamp - this.lastTapEvent.timeStamp < PeerTubeMobilePlugin.DOUBLE_TAP_DELAY_MS) { + logger('Detected double tap') + + this.lastTapEvent = undefined + this.onDoubleTap(event) + return + } + + this.newActiveState = !this.player.userActive() + + this.tapTimeout = setTimeout(() => { + logger('No double tap detected, set user active state to %s.', this.newActiveState) + + this.player.userActive(this.newActiveState) + }, PeerTubeMobilePlugin.DOUBLE_TAP_DELAY_MS) + + this.lastTapEvent = event + } + + this.player.on('touchstart', (event: TouchEvent) => { + // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it + if (this.player.userActive()) return + + handleTouchStart(event) + }) + + this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => { + // Prevent mousemove/click events firing on the player, that conflict with our user active logic + event.preventDefault() + + handleTouchStart(event) + }, { passive: false }) + } + + private onDoubleTap (event: TouchEvent) { + const playerWidth = this.player.currentWidth() + + const rect = this.findPlayerTarget((event.target as HTMLElement)).getBoundingClientRect() + const offsetX = event.targetTouches[0].pageX - rect.left + + logger('Calculating double tap zone (player width: %d, offset X: %d)', playerWidth, offsetX) + + if (offsetX > 0.66 * playerWidth) { + if (this.seekAmount < 0) this.seekAmount = 0 + + this.seekAmount += 10 + + logger('Will forward %d seconds', this.seekAmount) + } else if (offsetX < 0.33 * playerWidth) { + if (this.seekAmount > 0) this.seekAmount = 0 + + this.seekAmount -= 10 + logger('Will rewind %d seconds', this.seekAmount) + } + + this.peerTubeMobileButtons.displayFastSeek(this.seekAmount) + + this.scheduleSetCurrentTime() + } + + private findPlayerTarget (target: HTMLElement): HTMLElement { + if (target.classList.contains('video-js')) return target + + return this.findPlayerTarget(target.parentElement) + } + + private scheduleSetCurrentTime () { + this.player.pause() + this.player.addClass('vjs-fast-seeking') + + if (this.setCurrentTimeTimeout) clearTimeout(this.setCurrentTimeTimeout) + + this.setCurrentTimeTimeout = setTimeout(() => { + let newTime = this.player.currentTime() + this.seekAmount + this.seekAmount = 0 + + newTime = Math.max(0, newTime) + newTime = Math.min(this.player.duration(), newTime) + + this.player.currentTime(newTime) + this.seekAmount = 0 + this.peerTubeMobileButtons.displayFastSeek(0) + + this.player.removeClass('vjs-fast-seeking') + this.player.userActive(false) + + this.player.play() + }, PeerTubeMobilePlugin.SET_CURRENT_TIME_DELAY) + } +} + +videojs.registerPlugin('peertubeMobile', PeerTubeMobilePlugin) +export { PeerTubeMobilePlugin } diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts new file mode 100644 index 000000000..d0105fa36 --- /dev/null +++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts @@ -0,0 +1,419 @@ +// Thanks https://github.com/streamroot/videojs-hlsjs-plugin +// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file + +import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js' +import videojs from 'video.js' +import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types' + +type ErrorCounts = { + [ type: string ]: number +} + +type Metadata = { + levels: Level[] +} + +type HookFn = (player: videojs.Player, hljs: Hlsjs) => void + +const registerSourceHandler = function (vjs: typeof videojs) { + if (!Hlsjs.isSupported()) { + console.warn('Hls.js is not supported in this browser!') + return + } + + const html5 = vjs.getTech('Html5') + + if (!html5) { + console.error('No Hml5 tech found in videojs') + return + } + + // FIXME: typings + (html5 as any).registerSourceHandler({ + canHandleSource: function (source: videojs.Tech.SourceObject) { + const hlsTypeRE = /^application\/x-mpegURL|application\/vnd\.apple\.mpegurl$/i + const hlsExtRE = /\.m3u8/i + + if (hlsTypeRE.test(source.type)) return 'probably' + if (hlsExtRE.test(source.src)) return 'maybe' + + return '' + }, + + handleSource: function (source: videojs.Tech.SourceObject, tech: VideoJSTechHLS) { + if (tech.hlsProvider) { + tech.hlsProvider.dispose() + } + + tech.hlsProvider = new Html5Hlsjs(vjs, source, tech) + + return tech.hlsProvider + } + }, 0); + + // FIXME: typings + (vjs as any).Html5Hlsjs = Html5Hlsjs +} + +function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { + const player = this + + if (!options) return + + if (!player.srOptions_) { + player.srOptions_ = {} + } + + if (!player.srOptions_.hlsjsConfig) { + player.srOptions_.hlsjsConfig = options.hlsjsConfig + } + + if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { + player.srOptions_.levelLabelHandler = options.levelLabelHandler + } +} + +const registerConfigPlugin = function (vjs: typeof videojs) { + // Used in Brightcove since we don't pass options directly there + const registerVjsPlugin = vjs.registerPlugin || vjs.plugin + registerVjsPlugin('hlsjs', hlsjsConfigHandler) +} + +class Html5Hlsjs { + private static readonly hooks: { [id: string]: HookFn[] } = {} + + private readonly videoElement: HTMLVideoElement + private readonly errorCounts: ErrorCounts = {} + private readonly player: videojs.Player + private readonly tech: videojs.Tech + private readonly source: videojs.Tech.SourceObject + private readonly vjs: typeof videojs + + private maxNetworkErrorRecovery = 5 + + private hls: Hlsjs + private hlsjsConfig: Partial = null + + private _duration: number = null + private metadata: Metadata = null + private isLive: boolean = null + private dvrDuration: number = null + private edgeMargin: number = null + + private handlers: { [ id in 'play' ]: EventListener } = { + play: null + } + + constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { + this.vjs = vjs + this.source = source + + this.tech = tech; + (this.tech as any).name_ = 'Hlsjs' + + this.videoElement = tech.el() as HTMLVideoElement + this.player = vjs((tech.options_ as any).playerId) + + this.videoElement.addEventListener('error', event => { + let errorTxt: string + const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error + + if (!mediaError) return + + console.log(mediaError) + switch (mediaError.code) { + case mediaError.MEDIA_ERR_ABORTED: + errorTxt = 'You aborted the video playback' + break + case mediaError.MEDIA_ERR_DECODE: + errorTxt = 'The video playback was aborted due to a corruption problem or because the video used features ' + + 'your browser did not support' + this._handleMediaError(mediaError) + break + case mediaError.MEDIA_ERR_NETWORK: + errorTxt = 'A network error caused the video download to fail part-way' + break + case mediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + errorTxt = 'The video could not be loaded, either because the server or network failed or because the format is not supported' + break + + default: + errorTxt = mediaError.message + } + + console.error('MEDIA_ERROR: ', errorTxt) + }) + + this.initialize() + } + + duration () { + if (this._duration === Infinity) return Infinity + if (!isNaN(this.videoElement.duration)) return this.videoElement.duration + + return this._duration || 0 + } + + seekable () { + if (this.hls.media) { + if (!this.isLive) { + return this.vjs.createTimeRanges(0, this.hls.media.duration) + } + + // Video.js doesn't seem to like floating point timeranges + const startTime = Math.round(this.hls.media.duration - this.dvrDuration) + const endTime = Math.round(this.hls.media.duration - this.edgeMargin) + + return this.vjs.createTimeRanges(startTime, endTime) + } + + return this.vjs.createTimeRanges() + } + + // See comment for `initialize` method. + dispose () { + this.videoElement.removeEventListener('play', this.handlers.play) + + // FIXME: https://github.com/video-dev/hls.js/issues/4092 + const untypedHLS = this.hls as any + untypedHLS.log = untypedHLS.warn = () => { + // empty + } + + this.hls.destroy() + } + + static addHook (type: string, callback: HookFn) { + Html5Hlsjs.hooks[type] = this.hooks[type] || [] + Html5Hlsjs.hooks[type].push(callback) + } + + static removeHook (type: string, callback: HookFn) { + if (Html5Hlsjs.hooks[type] === undefined) return false + + const index = Html5Hlsjs.hooks[type].indexOf(callback) + if (index === -1) return false + + Html5Hlsjs.hooks[type].splice(index, 1) + + return true + } + + private _executeHooksFor (type: string) { + if (Html5Hlsjs.hooks[type] === undefined) { + return + } + + // ES3 and IE < 9 + for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) { + Html5Hlsjs.hooks[type][i](this.player, this.hls) + } + } + + private _handleMediaError (error: any) { + if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 1) { + console.info('trying to recover media error') + this.hls.recoverMediaError() + return + } + + if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 2) { + console.info('2nd try to recover media error (by swapping audio codec') + this.hls.swapAudioCodec() + this.hls.recoverMediaError() + return + } + + if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] > 2) { + console.info('bubbling media error up to VIDEOJS') + this.hls.destroy() + this.tech.error = () => error + this.tech.trigger('error') + } + } + + private _handleNetworkError (error: any) { + if (this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] <= this.maxNetworkErrorRecovery) { + console.info('trying to recover network error') + + // Wait 1 second and retry + setTimeout(() => this.hls.startLoad(), 1000) + + // Reset error count on success + this.hls.once(Hlsjs.Events.FRAG_LOADED, () => { + this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] = 0 + }) + + return + } + + console.info('bubbling network error up to VIDEOJS') + this.hls.destroy() + this.tech.error = () => error + this.tech.trigger('error') + } + + private _onError (_event: any, data: ErrorData) { + const error: { message: string, code?: number } = { + message: `HLS.js error: ${data.type} - fatal: ${data.fatal} - ${data.details}` + } + + // increment/set error count + if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1 + else this.errorCounts[data.type] = 1 + + if (data.fatal) console.warn(error.message) + else console.error(error.message, data) + + if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) { + error.code = 2 + this._handleNetworkError(error) + } else if (data.fatal && data.type === Hlsjs.ErrorTypes.MEDIA_ERROR && data.details !== 'manifestIncompatibleCodecsError') { + error.code = 3 + this._handleMediaError(error) + } else if (data.fatal) { + this.hls.destroy() + console.info('bubbling error up to VIDEOJS') + this.tech.error = () => error as any + this.tech.trigger('error') + } + } + + private buildLevelLabel (level: Level) { + if (this.player.srOptions_.levelLabelHandler) { + return this.player.srOptions_.levelLabelHandler(level as any) + } + + if (level.height) return level.height + 'p' + if (level.width) return Math.round(level.width * 9 / 16) + 'p' + if (level.bitrate) return (level.bitrate / 1000) + 'kbps' + + return '0' + } + + private _notifyVideoQualities () { + if (!this.metadata) return + + const resolutions: PeerTubeResolution[] = [] + + this.metadata.levels.forEach((level, index) => { + resolutions.push({ + id: index, + height: level.height, + width: level.width, + bitrate: level.bitrate, + label: this.buildLevelLabel(level), + selected: level.id === this.hls.manualLevel, + + selectCallback: () => { + this.hls.currentLevel = index + } + }) + }) + + resolutions.push({ + id: -1, + label: this.player.localize('Auto'), + selected: true, + selectCallback: () => this.hls.currentLevel = -1 + }) + + this.player.peertubeResolutions().add(resolutions) + } + + private _startLoad () { + this.hls.startLoad(-1) + this.videoElement.removeEventListener('play', this.handlers.play) + } + + private _oneLevelObjClone (obj: { [ id: string ]: any }) { + const result = {} + const objKeys = Object.keys(obj) + for (let i = 0; i < objKeys.length; i++) { + result[objKeys[i]] = obj[objKeys[i]] + } + + return result + } + + private _onMetaData (_event: any, data: ManifestParsedData) { + // This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later + this.metadata = data + this._notifyVideoQualities() + } + + private _initHlsjs () { + const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions + const srOptions_ = this.player.srOptions_ + + const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig + // Hls.js will write to the reference thus change the object for later streams + this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {} + + if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) { + this.hlsjsConfig.autoStartLoad = false + } + + // If the user explicitly sets autoStartLoad to false, we're not going to enter the if block above + // That's why we have a separate if block here to set the 'play' listener + if (this.hlsjsConfig.autoStartLoad === false) { + this.handlers.play = this._startLoad.bind(this) + this.videoElement.addEventListener('play', this.handlers.play) + } + + this.hls = new Hlsjs(this.hlsjsConfig) + + this._executeHooksFor('beforeinitialize') + + this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data)) + this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data)) + this.hls.on(Hlsjs.Events.LEVEL_LOADED, (event, data) => { + // The DVR plugin will auto seek to "live edge" on start up + if (this.hlsjsConfig.liveSyncDuration) { + this.edgeMargin = this.hlsjsConfig.liveSyncDuration + } else if (this.hlsjsConfig.liveSyncDurationCount) { + this.edgeMargin = this.hlsjsConfig.liveSyncDurationCount * data.details.targetduration + } + + this.isLive = data.details.live + this.dvrDuration = data.details.totalduration + + this._duration = this.isLive ? Infinity : data.details.totalduration + + // Increase network error recovery for lives since they can be broken (server restart, stream interruption etc) + if (this.isLive) this.maxNetworkErrorRecovery = 300 + }) + + this.hls.once(Hlsjs.Events.FRAG_LOADED, () => { + // Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls` + // Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata + this.tech.trigger('loadedmetadata') + }) + + this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => { + const resolutionId = this.hls.autoLevelEnabled + ? -1 + : data.level + + const autoResolutionChosenId = this.hls.autoLevelEnabled + ? data.level + : -1 + + this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) + }) + + this.hls.attachMedia(this.videoElement) + + this.hls.loadSource(this.source.src) + } + + private initialize () { + this._initHlsjs() + } +} + +export { + Html5Hlsjs, + registerSourceHandler, + registerConfigPlugin +} diff --git a/client/src/assets/player/shared/p2p-media-loader/index.ts b/client/src/assets/player/shared/p2p-media-loader/index.ts new file mode 100644 index 000000000..02fe71e73 --- /dev/null +++ b/client/src/assets/player/shared/p2p-media-loader/index.ts @@ -0,0 +1,5 @@ +export * from './hls-plugin' +export * from './p2p-media-loader-plugin' +export * from './redundancy-url-manager' +export * from './segment-url-builder' +export * from './segment-validator' diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts new file mode 100644 index 000000000..5c0f0021f --- /dev/null +++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts @@ -0,0 +1,183 @@ +import Hlsjs from 'hls.js' +import videojs from 'video.js' +import { Events, Segment } from '@peertube/p2p-media-loader-core' +import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' +import { timeToInt } from '@shared/core-utils' +import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' +import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' + +registerConfigPlugin(videojs) +registerSourceHandler(videojs) + +const Plugin = videojs.getPlugin('plugin') +class P2pMediaLoaderPlugin extends Plugin { + + private readonly CONSTANTS = { + INFO_SCHEDULER: 1000 // Don't change this + } + private readonly options: P2PMediaLoaderPluginOptions + + private hlsjs: Hlsjs + private p2pEngine: Engine + private statsP2PBytes = { + pendingDownload: [] as number[], + pendingUpload: [] as number[], + numPeers: 0, + totalDownload: 0, + totalUpload: 0 + } + private statsHTTPBytes = { + pendingDownload: [] as number[], + pendingUpload: [] as number[], + totalDownload: 0, + totalUpload: 0 + } + private startTime: number + + private networkInfoInterval: any + + constructor (player: videojs.Player, options?: P2PMediaLoaderPluginOptions) { + super(player) + + this.options = options + + // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 + if (!(videojs as any).Html5Hlsjs) { + console.warn('HLS.js does not seem to be supported. Try to fallback to built in HLS.') + + if (!player.canPlayType('application/vnd.apple.mpegurl')) { + const message = 'Cannot fallback to built-in HLS' + console.warn(message) + + player.ready(() => player.trigger('error', new Error(message))) + return + } + } else { + // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 + (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => { + this.hlsjs = hlsjs + }) + + initVideoJsContribHlsJsPlayer(player) + } + + this.startTime = timeToInt(options.startTime) + + player.src({ + type: options.type, + src: options.src + }) + + player.ready(() => { + this.initializeCore() + + if ((videojs as any).Html5Hlsjs) { + this.initializePlugin() + } + }) + } + + dispose () { + if (this.hlsjs) this.hlsjs.destroy() + if (this.p2pEngine) this.p2pEngine.destroy() + + clearInterval(this.networkInfoInterval) + } + + getCurrentLevel () { + return this.hlsjs.levels[this.hlsjs.currentLevel] + } + + getLiveLatency () { + return Math.round(this.hlsjs.latency) + } + + getHLSJS () { + return this.hlsjs + } + + private initializeCore () { + this.player.one('play', () => { + this.player.addClass('vjs-has-big-play-button-clicked') + }) + + this.player.one('canplay', () => { + if (this.startTime) { + this.player.currentTime(this.startTime) + } + }) + } + + private initializePlugin () { + initHlsJsPlayer(this.hlsjs) + + this.p2pEngine = this.options.loader.getEngine() + + this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => { + console.error('Segment error.', segment, err) + + this.options.redundancyUrlManager.removeBySegmentUrl(segment.requestUrl) + }) + + this.statsP2PBytes.numPeers = 1 + this.options.redundancyUrlManager.countBaseUrls() + + this.runStats() + } + + private runStats () { + this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, _segment, bytes: number) => { + const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes + + elem.pendingDownload.push(bytes) + elem.totalDownload += bytes + }) + + this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, _segment, bytes: number) => { + const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes + + elem.pendingUpload.push(bytes) + elem.totalUpload += bytes + }) + + this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++) + this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--) + + this.networkInfoInterval = setInterval(() => { + const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload) + const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload) + + const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload) + const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload) + + this.statsP2PBytes.pendingDownload = [] + this.statsP2PBytes.pendingUpload = [] + this.statsHTTPBytes.pendingDownload = [] + this.statsHTTPBytes.pendingUpload = [] + + return this.player.trigger('p2pInfo', { + source: 'p2p-media-loader', + http: { + downloadSpeed: httpDownloadSpeed, + uploadSpeed: httpUploadSpeed, + downloaded: this.statsHTTPBytes.totalDownload, + uploaded: this.statsHTTPBytes.totalUpload + }, + p2p: { + downloadSpeed: p2pDownloadSpeed, + uploadSpeed: p2pUploadSpeed, + numPeers: this.statsP2PBytes.numPeers, + downloaded: this.statsP2PBytes.totalDownload, + uploaded: this.statsP2PBytes.totalUpload + }, + bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 + } as PlayerNetworkInfo) + }, this.CONSTANTS.INFO_SCHEDULER) + } + + private arraySum (data: number[]) { + return data.reduce((a: number, b: number) => a + b, 0) + } +} + +videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) +export { P2pMediaLoaderPlugin } diff --git a/client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts b/client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts new file mode 100644 index 000000000..abab8aa99 --- /dev/null +++ b/client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts @@ -0,0 +1,42 @@ +import { basename, dirname } from 'path' + +class RedundancyUrlManager { + + constructor (private baseUrls: string[] = []) { + // empty + } + + removeBySegmentUrl (segmentUrl: string) { + console.log('Removing redundancy of segment URL %s.', segmentUrl) + + const baseUrl = dirname(segmentUrl) + + this.baseUrls = this.baseUrls.filter(u => u !== baseUrl && u !== baseUrl + '/') + } + + buildUrl (url: string) { + const max = this.baseUrls.length + 1 + const i = this.getRandomInt(max) + + if (i === max - 1) return url + + const newBaseUrl = this.baseUrls[i] + const slashPart = newBaseUrl.endsWith('/') ? '' : '/' + + return newBaseUrl + slashPart + basename(url) + } + + countBaseUrls () { + return this.baseUrls.length + } + + private getRandomInt (max: number) { + return Math.floor(Math.random() * Math.floor(max)) + } +} + +// --------------------------------------------------------------------------- + +export { + RedundancyUrlManager +} diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts new file mode 100644 index 000000000..9d324078a --- /dev/null +++ b/client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts @@ -0,0 +1,17 @@ +import { Segment } from '@peertube/p2p-media-loader-core' +import { RedundancyUrlManager } from './redundancy-url-manager' + +function segmentUrlBuilderFactory (redundancyUrlManager: RedundancyUrlManager, useOriginPriority: number) { + return function segmentBuilder (segment: Segment) { + // Don't use redundancy for high priority segments + if (segment.priority <= useOriginPriority) return segment.url + + return redundancyUrlManager.buildUrl(segment.url) + } +} + +// --------------------------------------------------------------------------- + +export { + segmentUrlBuilderFactory +} diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts new file mode 100644 index 000000000..f7f83a8a4 --- /dev/null +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts @@ -0,0 +1,106 @@ +import { wait } from '@root-helpers/utils' +import { Segment } from '@peertube/p2p-media-loader-core' +import { basename } from 'path' + +type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } } + +const maxRetries = 3 + +function segmentValidatorFactory (segmentsSha256Url: string, isLive: boolean) { + let segmentsJSON = fetchSha256Segments(segmentsSha256Url) + const regex = /bytes=(\d+)-(\d+)/ + + return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { + // Wait for hash generation from the server + if (isLive) await wait(1000) + + const filename = basename(segment.url) + + const segmentValue = (await segmentsJSON)[filename] + + if (!segmentValue && retry > maxRetries) { + throw new Error(`Unknown segment name ${filename} in segment validator`) + } + + if (!segmentValue) { + console.log('Refetching sha segments for %s.', filename) + + await wait(1000) + + segmentsJSON = fetchSha256Segments(segmentsSha256Url) + await segmentValidator(segment, _method, _peerId, retry + 1) + + return + } + + let hashShouldBe: string + let range = '' + + if (typeof segmentValue === 'string') { + hashShouldBe = segmentValue + } else { + const captured = regex.exec(segment.range) + range = captured[1] + '-' + captured[2] + + hashShouldBe = segmentValue[range] + } + + if (hashShouldBe === undefined) { + throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) + } + + const calculatedSha = await sha256Hex(segment.data) + if (calculatedSha !== hashShouldBe) { + throw new Error( + `Hashes does not correspond for segment ${filename}/${range}` + + `(expected: ${hashShouldBe} instead of ${calculatedSha})` + ) + } + } +} + +// --------------------------------------------------------------------------- + +export { + segmentValidatorFactory +} + +// --------------------------------------------------------------------------- + +function fetchSha256Segments (url: string) { + return fetch(url) + .then(res => res.json() as Promise) + .catch(err => { + console.error('Cannot get sha256 segments', err) + return {} + }) +} + +async function sha256Hex (data?: ArrayBuffer) { + if (!data) return undefined + + if (window.crypto.subtle) { + return window.crypto.subtle.digest('SHA-256', data) + .then(data => bufferToHex(data)) + } + + // Fallback for non HTTPS context + const shaModule = (await import('sha.js') as any).default + // eslint-disable-next-line new-cap + return new shaModule.sha256().update(Buffer.from(data)).digest('hex') +} + +// Thanks: https://stackoverflow.com/a/53307879 +function bufferToHex (buffer?: ArrayBuffer) { + if (!buffer) return '' + + let s = '' + const h = '0123456789abcdef' + const o = new Uint8Array(buffer) + + o.forEach((v: any) => { + s += h[v >> 4] + h[v & 15] + }) + + return s +} diff --git a/client/src/assets/player/shared/peertube/index.ts b/client/src/assets/player/shared/peertube/index.ts new file mode 100644 index 000000000..ff4d5241b --- /dev/null +++ b/client/src/assets/player/shared/peertube/index.ts @@ -0,0 +1 @@ +export * from './peertube-plugin' diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts new file mode 100644 index 000000000..1dc3e3de0 --- /dev/null +++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts @@ -0,0 +1,302 @@ +import debug from 'debug' +import videojs from 'video.js' +import { isMobile } from '@root-helpers/web-browser' +import { timeToInt } from '@shared/core-utils' +import { + getStoredLastSubtitle, + getStoredMute, + getStoredVolume, + saveLastSubtitle, + saveMuteInStore, + saveVideoWatchHistory, + saveVolumeInStore +} from '../../peertube-player-local-storage' +import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types' +import { SettingsButton } from '../settings/settings-menu-button' + +const logger = debug('peertube:player:peertube') + +const Plugin = videojs.getPlugin('plugin') + +class PeerTubePlugin extends Plugin { + private readonly videoViewUrl: string + private readonly videoDuration: number + private readonly CONSTANTS = { + USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video + } + + private videoCaptions: VideoJSCaption[] + private defaultSubtitle: string + + private videoViewInterval: any + private userWatchingVideoInterval: any + + private isLive: boolean + + private menuOpened = false + private mouseInControlBar = false + private mouseInSettings = false + private readonly initialInactivityTimeout: number + + constructor (player: videojs.Player, options?: PeerTubePluginOptions) { + super(player) + + this.videoViewUrl = options.videoViewUrl + this.videoDuration = options.videoDuration + this.videoCaptions = options.videoCaptions + this.isLive = options.isLive + this.initialInactivityTimeout = this.player.options_.inactivityTimeout + + if (options.autoplay) this.player.addClass('vjs-has-autoplay') + + this.player.on('autoplay-failure', () => { + this.player.removeClass('vjs-has-autoplay') + }) + + this.player.ready(() => { + const playerOptions = this.player.options_ + + const volume = getStoredVolume() + if (volume !== undefined) this.player.volume(volume) + + const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() + if (muted !== undefined) this.player.muted(muted) + + this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() + + this.player.on('volumechange', () => { + saveVolumeInStore(this.player.volume()) + saveMuteInStore(this.player.muted()) + }) + + if (options.stopTime) { + const stopTime = timeToInt(options.stopTime) + const self = this + + this.player.on('timeupdate', function onTimeUpdate () { + if (self.player.currentTime() > stopTime) { + self.player.pause() + self.player.trigger('stopped') + + self.player.off('timeupdate', onTimeUpdate) + } + }) + } + + this.player.textTracks().addEventListener('change', () => { + const showing = this.player.textTracks().tracks_.find(t => { + return t.kind === 'captions' && t.mode === 'showing' + }) + + if (!showing) { + saveLastSubtitle('off') + return + } + + saveLastSubtitle(showing.language) + }) + + this.player.on('sourcechange', () => this.initCaptions()) + + this.player.duration(options.videoDuration) + + this.initializePlayer() + this.runViewAdd() + + this.runUserWatchVideo(options.userWatching, options.videoUUID) + }) + } + + dispose () { + if (this.videoViewInterval) clearInterval(this.videoViewInterval) + if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) + } + + onMenuOpened () { + this.menuOpened = true + this.alterInactivity() + } + + onMenuClosed () { + this.menuOpened = false + this.alterInactivity() + } + + displayFatalError () { + this.player.addClass('vjs-error-display-enabled') + } + + hideFatalError () { + this.player.removeClass('vjs-error-display-enabled') + } + + private initializePlayer () { + if (isMobile()) this.player.addClass('vjs-is-mobile') + + this.initSmoothProgressBar() + + this.initCaptions() + + this.listenControlBarMouse() + + this.listenFullScreenChange() + } + + private runViewAdd () { + this.clearVideoViewInterval() + + // After 30 seconds (or 3/4 of the video), add a view to the video + let minSecondsToView = 30 + + if (!this.isLive && this.videoDuration < minSecondsToView) { + minSecondsToView = (this.videoDuration * 3) / 4 + } + + let secondsViewed = 0 + this.videoViewInterval = setInterval(() => { + if (this.player && !this.player.paused()) { + secondsViewed += 1 + + if (secondsViewed > minSecondsToView) { + // Restart the loop if this is a live + if (this.isLive) { + secondsViewed = 0 + } else { + this.clearVideoViewInterval() + } + + this.addViewToVideo().catch(err => console.error(err)) + } + } + }, 1000) + } + + private runUserWatchVideo (options: UserWatching, videoUUID: string) { + let lastCurrentTime = 0 + + this.userWatchingVideoInterval = setInterval(() => { + const currentTime = Math.floor(this.player.currentTime()) + + if (currentTime - lastCurrentTime >= 1) { + lastCurrentTime = currentTime + + if (options) { + this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) + .catch(err => console.error('Cannot notify user is watching.', err)) + } else { + saveVideoWatchHistory(videoUUID, currentTime) + } + } + }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) + } + + private clearVideoViewInterval () { + if (this.videoViewInterval !== undefined) { + clearInterval(this.videoViewInterval) + this.videoViewInterval = undefined + } + } + + private addViewToVideo () { + if (!this.videoViewUrl) return Promise.resolve(undefined) + + return fetch(this.videoViewUrl, { method: 'POST' }) + } + + private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { + const body = new URLSearchParams() + body.append('currentTime', currentTime.toString()) + + const headers = new Headers({ Authorization: authorizationHeader }) + + return fetch(url, { method: 'PUT', body, headers }) + } + + private listenFullScreenChange () { + this.player.on('fullscreenchange', () => { + if (this.player.isFullscreen()) this.player.focus() + }) + } + + private listenControlBarMouse () { + const controlBar = this.player.controlBar + const settingsButton: SettingsButton = (controlBar as any).settingsButton + + controlBar.on('mouseenter', () => { + this.mouseInControlBar = true + this.alterInactivity() + }) + + controlBar.on('mouseleave', () => { + this.mouseInControlBar = false + this.alterInactivity() + }) + + settingsButton.dialog.on('mouseenter', () => { + this.mouseInSettings = true + this.alterInactivity() + }) + + settingsButton.dialog.on('mouseleave', () => { + this.mouseInSettings = false + this.alterInactivity() + }) + } + + private alterInactivity () { + if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) { + this.setInactivityTimeout(0) + return + } + + this.setInactivityTimeout(this.initialInactivityTimeout) + this.player.reportUserActivity(true) + } + + private setInactivityTimeout (timeout: number) { + (this.player as any).cache_.inactivityTimeout = timeout + this.player.options_.inactivityTimeout = timeout + + logger('Set player inactivity to ' + timeout) + } + + private initCaptions () { + for (const caption of this.videoCaptions) { + this.player.addRemoteTextTrack({ + kind: 'captions', + label: caption.label, + language: caption.language, + id: caption.language, + src: caption.src, + default: this.defaultSubtitle === caption.language + }, false) + } + + this.player.trigger('captionsChanged') + } + + // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 + private initSmoothProgressBar () { + const SeekBar = videojs.getComponent('SeekBar') as any + SeekBar.prototype.getPercent = function getPercent () { + // Allows for smooth scrubbing, when player can't keep up. + // const time = (this.player_.scrubbing()) ? + // this.player_.getCache().currentTime : + // this.player_.currentTime() + const time = this.player_.currentTime() + const percent = time / this.player_.duration() + return percent >= 1 ? 1 : percent + } + SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) { + let newTime = this.calculateDistance(event) * this.player_.duration() + if (newTime === this.player_.duration()) { + newTime = newTime - 0.1 + } + this.player_.currentTime(newTime) + this.update() + } + } +} + +videojs.registerPlugin('peertube', PeerTubePlugin) +export { PeerTubePlugin } diff --git a/client/src/assets/player/shared/playlist/index.ts b/client/src/assets/player/shared/playlist/index.ts new file mode 100644 index 000000000..0be6e4d3c --- /dev/null +++ b/client/src/assets/player/shared/playlist/index.ts @@ -0,0 +1,4 @@ +export * from './playlist-button' +export * from './playlist-menu-item' +export * from './playlist-menu' +export * from './playlist-plugin' diff --git a/client/src/assets/player/shared/playlist/playlist-button.ts b/client/src/assets/player/shared/playlist/playlist-button.ts new file mode 100644 index 000000000..6cfaf4158 --- /dev/null +++ b/client/src/assets/player/shared/playlist/playlist-button.ts @@ -0,0 +1,61 @@ +import videojs from 'video.js' +import { PlaylistPluginOptions } from '../../types' +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/shared/playlist/playlist-menu-item.ts b/client/src/assets/player/shared/playlist/playlist-menu-item.ts new file mode 100644 index 000000000..81b5acf30 --- /dev/null +++ b/client/src/assets/player/shared/playlist/playlist-menu-item.ts @@ -0,0 +1,136 @@ +import videojs from 'video.js' +import { secondsToTime } from '@shared/core-utils' +import { VideoPlaylistElement } from '@shared/models' +import { PlaylistItemOptions } from '../../types' + +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 + + if (!options.element.video) { + li.classList.add('vjs-disabled') + } + + const positionBlock = super.createEl('div', { + className: 'item-position-block' + }) as HTMLElement + + const position = super.createEl('div', { + className: 'item-position', + innerHTML: options.element.position + }) + + positionBlock.appendChild(position) + li.appendChild(positionBlock) + + if (options.element.video) { + this.buildAvailableVideo(li, positionBlock, options) + } else { + this.buildUnavailableVideo(li) + } + + return li + } + + setSelected (selected: boolean) { + if (selected) this.addClass('vjs-selected') + else this.removeClass('vjs-selected') + } + + getElement () { + return this.element + } + + private buildAvailableVideo (li: HTMLElement, positionBlock: HTMLElement, options: PlaylistItemOptions) { + const videoElement = options.element + + const player = super.createEl('div', { + className: 'item-player' + }) + + positionBlock.appendChild(player) + + const thumbnail = super.createEl('img', { + src: window.location.origin + videoElement.video.thumbnailPath + }) + + const infoBlock = super.createEl('div', { + className: 'info-block' + }) + + const title = super.createEl('div', { + innerHTML: videoElement.video.name, + className: 'title' + }) + + const channel = super.createEl('div', { + innerHTML: videoElement.video.channel.displayName, + className: 'channel' + }) + + infoBlock.appendChild(title) + infoBlock.appendChild(channel) + + if (videoElement.startTimestamp || videoElement.stopTimestamp) { + let html = '' + + if (videoElement.startTimestamp) html += secondsToTime(videoElement.startTimestamp) + if (videoElement.stopTimestamp) html += ' - ' + secondsToTime(videoElement.stopTimestamp) + + const timestamps = super.createEl('div', { + innerHTML: html, + className: 'timestamps' + }) + + infoBlock.append(timestamps) + } + + li.append(thumbnail) + li.append(infoBlock) + } + + private buildUnavailableVideo (li: HTMLElement) { + const block = super.createEl('div', { + className: 'item-unavailable', + innerHTML: this.player().localize('Unavailable video') + }) + + li.appendChild(block) + } + + 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/shared/playlist/playlist-menu.ts b/client/src/assets/player/shared/playlist/playlist-menu.ts new file mode 100644 index 000000000..1ec9ac804 --- /dev/null +++ b/client/src/assets/player/shared/playlist/playlist-menu.ts @@ -0,0 +1,137 @@ +import videojs from 'video.js' +import { VideoPlaylistElement } from '@shared/models' +import { PlaylistPluginOptions } from '../../types' +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) + + const self = this + + function userInactiveHandler () { + self.close() + } + + this.el().addEventListener('mouseenter', () => { + this.player().off('userinactive', userInactiveHandler) + }) + + this.el().addEventListener('mouseleave', () => { + this.player().one('userinactive', userInactiveHandler) + }) + + 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 playlistChannel = options.playlist.videoChannel + const leftSubtitle = super.createEl('div', { + innerHTML: playlistChannel + ? this.player().localize('By {1}', [ playlistChannel.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/shared/playlist/playlist-plugin.ts b/client/src/assets/player/shared/playlist/playlist-plugin.ts new file mode 100644 index 000000000..44de0da5a --- /dev/null +++ b/client/src/assets/player/shared/playlist/playlist-plugin.ts @@ -0,0 +1,35 @@ +import videojs from 'video.js' +import { PlaylistPluginOptions } from '../../types' +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, { ...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/assets/player/shared/resolutions/index.ts b/client/src/assets/player/shared/resolutions/index.ts new file mode 100644 index 000000000..e56473f43 --- /dev/null +++ b/client/src/assets/player/shared/resolutions/index.ts @@ -0,0 +1 @@ +export * from './peertube-resolutions-plugin' diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts new file mode 100644 index 000000000..e7899ac71 --- /dev/null +++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts @@ -0,0 +1,88 @@ +import videojs from 'video.js' +import { PeerTubeResolution } from '../../types' + +const Plugin = videojs.getPlugin('plugin') + +class PeerTubeResolutionsPlugin extends Plugin { + private currentSelection: PeerTubeResolution + private resolutions: PeerTubeResolution[] = [] + + private autoResolutionChosenId: number + private autoResolutionEnabled = true + + add (resolutions: PeerTubeResolution[]) { + for (const r of resolutions) { + this.resolutions.push(r) + } + + this.currentSelection = this.getSelected() + + this.sort() + this.trigger('resolutionsAdded') + } + + getResolutions () { + return this.resolutions + } + + getSelected () { + return this.resolutions.find(r => r.selected) + } + + getAutoResolutionChosen () { + return this.resolutions.find(r => r.id === this.autoResolutionChosenId) + } + + select (options: { + id: number + byEngine: boolean + autoResolutionChosenId?: number + }) { + const { id, autoResolutionChosenId, byEngine } = options + + if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return + + this.autoResolutionChosenId = autoResolutionChosenId + + for (const r of this.resolutions) { + r.selected = r.id === id + + if (r.selected) { + this.currentSelection = r + + if (!byEngine) r.selectCallback() + } + } + + this.trigger('resolutionChanged') + } + + disableAutoResolution () { + this.autoResolutionEnabled = false + this.trigger('autoResolutionEnabledChanged') + } + + enabledAutoResolution () { + this.autoResolutionEnabled = true + this.trigger('autoResolutionEnabledChanged') + } + + isAutoResolutionEnabeld () { + return this.autoResolutionEnabled + } + + private sort () { + this.resolutions.sort((a, b) => { + if (a.id === -1) return 1 + if (b.id === -1) return -1 + + if (a.height > b.height) return -1 + if (a.height === b.height) return 0 + return 1 + }) + } + +} + +videojs.registerPlugin('peertubeResolutions', PeerTubeResolutionsPlugin) +export { PeerTubeResolutionsPlugin } diff --git a/client/src/assets/player/shared/settings/index.ts b/client/src/assets/player/shared/settings/index.ts new file mode 100644 index 000000000..736d50c16 --- /dev/null +++ b/client/src/assets/player/shared/settings/index.ts @@ -0,0 +1,7 @@ +export * from './resolution-menu-button' +export * from './resolution-menu-item' +export * from './settings-dialog' +export * from './settings-menu-button' +export * from './settings-menu-item' +export * from './settings-panel-child' +export * from './settings-panel' diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts new file mode 100644 index 000000000..8bd5b4f03 --- /dev/null +++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts @@ -0,0 +1,86 @@ +import videojs from 'video.js' +import { ResolutionMenuItem } from './resolution-menu-item' + +const Menu = videojs.getComponent('Menu') +const MenuButton = videojs.getComponent('MenuButton') +class ResolutionMenuButton extends MenuButton { + labelEl_: HTMLElement + + constructor (player: videojs.Player, options?: videojs.MenuButtonOptions) { + super(player, options) + + this.controlText('Quality') + + player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) + + // For parent + player.peertubeResolutions().on('resolutionChanged', () => { + setTimeout(() => this.trigger('labelUpdated')) + }) + } + + createEl () { + const el = super.createEl() + + this.labelEl_ = videojs.dom.createEl('div', { + className: 'vjs-resolution-value' + }) as HTMLElement + + el.appendChild(this.labelEl_) + + return el + } + + updateARIAAttributes () { + this.el().setAttribute('aria-label', 'Quality') + } + + createMenu () { + return new Menu(this.player_) + } + + buildCSSClass () { + return super.buildCSSClass() + ' vjs-resolution-button' + } + + buildWrapperCSSClass () { + return 'vjs-resolution-control ' + super.buildWrapperCSSClass() + } + + private addClickListener (component: any) { + component.on('click', () => { + const children = this.menu.children() + + for (const child of children) { + if (component !== child) { + (child as videojs.MenuItem).selected(false) + } + } + }) + } + + private buildQualities () { + for (const d of this.player().peertubeResolutions().getResolutions()) { + const label = d.label === '0p' + ? this.player().localize('Audio-only') + : d.label + + this.menu.addChild(new ResolutionMenuItem( + this.player_, + { + id: d.id, + label, + selected: d.selected + }) + ) + } + + for (const m of this.menu.children()) { + this.addClickListener(m) + } + + this.trigger('menuChanged') + } +} + +videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton) diff --git a/client/src/assets/player/shared/settings/resolution-menu-item.ts b/client/src/assets/player/shared/settings/resolution-menu-item.ts new file mode 100644 index 000000000..6047f52f7 --- /dev/null +++ b/client/src/assets/player/shared/settings/resolution-menu-item.ts @@ -0,0 +1,77 @@ +import videojs from 'video.js' + +const MenuItem = videojs.getComponent('MenuItem') + +export interface ResolutionMenuItemOptions extends videojs.MenuItemOptions { + id: number +} + +class ResolutionMenuItem extends MenuItem { + private readonly resolutionId: number + private readonly label: string + + private autoResolutionEnabled: boolean + private autoResolutionChosen: string + + constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { + options.selectable = true + + super(player, options) + + this.autoResolutionEnabled = true + this.autoResolutionChosen = '' + + this.resolutionId = options.id + this.label = options.label + + player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection()) + + // We only want to disable the "Auto" item + if (this.resolutionId === -1) { + player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution()) + } + } + + handleClick (event: any) { + // Auto button disabled? + if (this.autoResolutionEnabled === false && this.resolutionId === -1) return + + super.handleClick(event) + + this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) + } + + updateSelection () { + const selectedResolution = this.player().peertubeResolutions().getSelected() + + if (this.resolutionId === -1) { + this.autoResolutionChosen = this.player().peertubeResolutions().getAutoResolutionChosen()?.label + } + + this.selected(this.resolutionId === selectedResolution.id) + } + + updateAutoResolution () { + const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld() + + // Check if the auto resolution is enabled or not + if (enabled === false) { + this.addClass('disabled') + } else { + this.removeClass('disabled') + } + + this.autoResolutionEnabled = enabled + } + + getLabel () { + if (this.resolutionId === -1) { + return this.label + ' ' + this.autoResolutionChosen + '' + } + + return this.label + } +} +videojs.registerComponent('ResolutionMenuItem', ResolutionMenuItem) + +export { ResolutionMenuItem } diff --git a/client/src/assets/player/shared/settings/settings-dialog.ts b/client/src/assets/player/shared/settings/settings-dialog.ts new file mode 100644 index 000000000..8cd98967f --- /dev/null +++ b/client/src/assets/player/shared/settings/settings-dialog.ts @@ -0,0 +1,35 @@ +import videojs from 'video.js' + +const Component = videojs.getComponent('Component') + +class SettingsDialog extends Component { + constructor (player: videojs.Player) { + super(player) + + this.hide() + } + + /** + * Create the component's DOM element + * + */ + 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 + }) + } +} + +Component.registerComponent('SettingsDialog', SettingsDialog) + +export { SettingsDialog } diff --git a/client/src/assets/player/shared/settings/settings-menu-button.ts b/client/src/assets/player/shared/settings/settings-menu-button.ts new file mode 100644 index 000000000..64866aab2 --- /dev/null +++ b/client/src/assets/player/shared/settings/settings-menu-button.ts @@ -0,0 +1,277 @@ +import videojs from 'video.js' +import { toTitleCase } from '../common' +import { SettingsDialog } from './settings-dialog' +import { SettingsMenuItem } from './settings-menu-item' +import { SettingsPanel } from './settings-panel' +import { SettingsPanelChild } from './settings-panel-child' + +const Button = videojs.getComponent('Button') +const Menu = videojs.getComponent('Menu') +const Component = videojs.getComponent('Component') + +export interface SettingsButtonOptions extends videojs.ComponentOptions { + entries: any[] + setup?: { + maxHeightOffset: number + } +} + +class SettingsButton extends Button { + dialog: SettingsDialog + dialogEl: HTMLElement + menu: videojs.Menu + panel: SettingsPanel + panelChild: SettingsPanelChild + + addSettingsItemHandler: typeof SettingsButton.prototype.onAddSettingsItem + disposeSettingsItemHandler: typeof SettingsButton.prototype.onDisposeSettingsItem + documentClickHandler: typeof SettingsButton.prototype.onDocumentClick + userInactiveHandler: typeof SettingsButton.prototype.onUserInactive + + private settingsButtonOptions: SettingsButtonOptions + + constructor (player: videojs.Player, options?: SettingsButtonOptions) { + super(player, options) + + this.settingsButtonOptions = options + + this.controlText('Settings') + + this.dialog = this.player().addChild('settingsDialog') + this.dialogEl = this.dialog.el() as HTMLElement + 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.documentClickHandler = this.onDocumentClick.bind(this) + this.userInactiveHandler = this.onUserInactive.bind(this) + + this.buildMenu() + this.bindEvents() + + // Prepare the dialog + this.player().one('play', () => this.hideDialog()) + } + + onDocumentClick (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: any, name: string) { + if (name === undefined) { + const children = this.menu.children() + + while (children.length > 0) { + children[0].dispose() + this.menu.removeChild(children[0]) + } + + this.addClass('vjs-hidden') + } else { + const item = this.menu.getChild(name) + + if (item) { + item.dispose() + this.menu.removeChild(item) + } + } + + this.hideDialog() + + if (this.settingsButtonOptions.entries.length === 0) { + this.addClass('vjs-hidden') + } + } + + dispose () { + document.removeEventListener('click', this.documentClickHandler) + + if (this.isInIframe()) { + window.removeEventListener('blur', this.documentClickHandler) + } + } + + onAddSettingsItem (event: any, data: any) { + const [ entry, options ] = data + + this.addMenuItem(entry, options) + this.removeClass('vjs-hidden') + } + + onUserInactive () { + if (!this.dialog.hasClass('vjs-hidden')) { + this.hideDialog() + } + } + + bindEvents () { + document.addEventListener('click', this.documentClickHandler) + if (this.isInIframe()) { + window.addEventListener('blur', this.documentClickHandler) + } + + this.player().on('addsettingsitem', this.addSettingsItemHandler) + this.player().on('disposesettingsitem', this.disposeSettingsItemHandler) + this.player().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.player().peertube().onMenuOpened(); + + (this.menu.el() as HTMLElement).style.opacity = '1' + + this.dialog.show() + this.el().setAttribute('aria-expanded', 'true') + + this.setDialogSize(this.getComponentSize(this.menu)) + + const firstChild = this.menu.children()[0] + if (firstChild) firstChild.focus() + } + + hideDialog () { + this.player_.peertube().onMenuClosed() + + this.dialog.hide() + this.el().setAttribute('aria-expanded', 'false') + + this.setDialogSize(this.getComponentSize(this.menu)); + (this.menu.el() as HTMLElement).style.opacity = '1' + this.resetChildren() + } + + getComponentSize (element: videojs.Component | HTMLElement) { + let width: number = null + let height: number = null + + // Could be component or just DOM element + if (element instanceof Component) { + const el = element.el() as HTMLElement + + width = el.offsetWidth + height = el.offsetHeight; + + (element as any).width = width; + (element as any).height = height + } else { + width = element.offsetWidth + height = element.offsetHeight + } + + return [ width, height ] + } + + setDialogSize ([ width, height ]: number[]) { + if (typeof height !== 'number') { + return + } + + const offset = this.settingsButtonOptions.setup.maxHeightOffset + const maxHeight = (this.player().el() as HTMLElement).offsetHeight - offset + + const panelEl = this.panel.el() as HTMLElement + + if (height > maxHeight) { + height = maxHeight + width += 17 + panelEl.style.maxHeight = `${height}px` + } else if (panelEl.style.maxHeight !== '') { + panelEl.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') + const entries = this.settingsButtonOptions.entries + + if (entries.length === 0) { + this.addClass('vjs-hidden') + this.panelChild.addChild(this.menu) + return + } + + for (const entry of entries) { + this.addMenuItem(entry, this.settingsButtonOptions) + } + + this.panelChild.addChild(this.menu) + } + + addMenuItem (entry: any, options: any) { + const openSubMenu = function (this: any) { + if (videojs.dom.hasClass(this.el_, 'open')) { + videojs.dom.removeClass(this.el_, 'open') + } else { + videojs.dom.addClass(this.el_, 'open') + } + } + + options.name = toTitleCase(entry) + + const newOptions = Object.assign({}, options, { entry, menuButton: this }) + const settingsMenuItem = new SettingsMenuItem(this.player(), newOptions) + + 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 (const menuChild of this.menu.children()) { + (menuChild as SettingsMenuItem).reset() + } + } + + /** + * Hide all the sub menus + */ + hideChildren () { + for (const menuChild of this.menu.children()) { + (menuChild as SettingsMenuItem).hideSubMenu() + } + } + + isInIframe () { + return window.self !== window.top + } + +} + +Component.registerComponent('SettingsButton', SettingsButton) + +export { SettingsButton } diff --git a/client/src/assets/player/shared/settings/settings-menu-item.ts b/client/src/assets/player/shared/settings/settings-menu-item.ts new file mode 100644 index 000000000..8d1819a2d --- /dev/null +++ b/client/src/assets/player/shared/settings/settings-menu-item.ts @@ -0,0 +1,377 @@ +import videojs from 'video.js' +import { toTitleCase } from '../common' +import { SettingsDialog } from './settings-dialog' +import { SettingsButton } from './settings-menu-button' +import { SettingsPanel } from './settings-panel' +import { SettingsPanelChild } from './settings-panel-child' + +const MenuItem = videojs.getComponent('MenuItem') +const component = videojs.getComponent('Component') + +export interface SettingsMenuItemOptions extends videojs.MenuItemOptions { + entry: string + menuButton: SettingsButton +} + +class SettingsMenuItem extends MenuItem { + settingsButton: SettingsButton + dialog: SettingsDialog + mainMenu: videojs.Menu + panel: SettingsPanel + panelChild: SettingsPanelChild + panelChildEl: HTMLElement + size: number[] + menuToLoad: string + subMenu: SettingsButton + + submenuClickHandler: typeof SettingsMenuItem.prototype.onSubmenuClick + transitionEndHandler: typeof SettingsMenuItem.prototype.onTransitionEnd + + settingsSubMenuTitleEl_: HTMLElement + settingsSubMenuValueEl_: HTMLElement + settingsSubMenuEl_: HTMLElement + + constructor (player: videojs.Player, options?: SettingsMenuItemOptions) { + super(player, options) + + this.settingsButton = options.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() as HTMLElement + + this.size = null + + // keep state of what menu type is loading next + this.menuToLoad = 'mainmenu' + + const subMenuName = toTitleCase(options.entry) + const SubMenuComponent = videojs.getComponent(subMenuName) + + if (!SubMenuComponent) { + throw new Error(`Component ${subMenuName} does not exist`) + } + + const newOptions = Object.assign({}, options, { entry: options.menuButton, menuButton: this }) + + this.subMenu = new SubMenuComponent(this.player(), newOptions) as SettingsButton + const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0] + this.settingsSubMenuEl_.className += ' ' + subMenuClass + + this.eventHandlers() + + player.ready(() => { + // Voodoo magic for IOS + setTimeout(() => { + // Player was destroyed + if (!this.player_) return + + this.build() + + // Update on rate change + player.on('ratechange', this.submenuClickHandler) + + if (subMenuName === 'CaptionsButton') { + // Hack to regenerate captions on HTTP fallback + player.on('captionsChanged', () => { + setTimeout(() => { + this.settingsSubMenuEl_.innerHTML = '' + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) + this.update() + this.bindClickEvents() + }, 0) + }) + } + + this.reset() + }, 0) + }) + } + + eventHandlers () { + this.submenuClickHandler = this.onSubmenuClick.bind(this) + this.transitionEndHandler = this.onTransitionEnd.bind(this) + } + + onSubmenuClick (event: any) { + let target = null + + if (event.type === 'tap') { + target = event.target + } else { + target = event.currentTarget || event.target + } + + 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) + + // Seems like videojs adds a vjs-hidden class on the caption menu after a click + // We don't need it + this.subMenu.menu.removeClass('vjs-hidden') + } + + /** + * Create the component's DOM element + * + */ + createEl () { + const el = videojs.dom.createEl('li', { + className: 'vjs-menu-item', + tabIndex: -1 + }) + + this.settingsSubMenuTitleEl_ = videojs.dom.createEl('div', { + className: 'vjs-settings-sub-menu-title' + }) as HTMLElement + + el.appendChild(this.settingsSubMenuTitleEl_) + + this.settingsSubMenuValueEl_ = videojs.dom.createEl('div', { + className: 'vjs-settings-sub-menu-value' + }) as HTMLElement + + el.appendChild(this.settingsSubMenuValueEl_) + + this.settingsSubMenuEl_ = videojs.dom.createEl('div', { + className: 'vjs-settings-sub-menu' + }) as HTMLElement + + return el as HTMLLIElement + } + + /** + * Handle click on menu item + * + * @method handleClick + */ + handleClick (event: videojs.EventTarget.Event) { + this.menuToLoad = 'submenu' + // Remove open class to ensure only the open submenu gets this class + videojs.dom.removeClass(this.el(), 'open') + + super.handleClick(event); + + (this.mainMenu.el() as HTMLElement).style.opacity = '0' + // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element + if (videojs.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { + videojs.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) + + const firstChild = this.subMenu.menu.children()[0] + if (firstChild) firstChild.focus() + } else { + videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + } + } + + /** + * Create back button + * + * @method createBackButton + */ + createBackButton () { + const button = this.subMenu.menu.addChild('MenuItem', {}, 0) + + button.addClass('vjs-back-button'); + (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) + } + + /** + * Add/remove prefixed event listener for CSS Transition + * + * @method PrefixedEvent + */ + PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { + const 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: any) { + if (event.propertyName !== 'margin-right') { + return + } + + if (this.menuToLoad === 'mainmenu') { + // hide submenu + videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + + // reset opacity to 0 + this.settingsSubMenuEl_.style.opacity = '0' + } + } + + reset () { + videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + this.settingsSubMenuEl_.style.opacity = '0' + this.setMargin() + } + + loadMainMenu () { + const mainMenuEl = this.mainMenu.el() as HTMLElement + this.menuToLoad = 'mainmenu' + this.mainMenu.show() + mainMenuEl.style.opacity = '0' + + // back button will always take you to main menu, so set dialog sizes + const mainMenuAny = this.mainMenu as any + this.settingsButton.setDialogSize([ mainMenuAny.width, mainMenuAny.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() + mainMenuEl.style.opacity = '1' + + const firstChild = this.mainMenu.children()[0] + if (firstChild) firstChild.focus() + }, 0) + } + + build () { + this.subMenu.on('labelUpdated', () => { + this.update() + }) + this.subMenu.on('menuChanged', () => { + this.bindClickEvents() + this.setSize() + this.update() + }) + + this.settingsSubMenuTitleEl_.innerHTML = this.player().localize(this.subMenu.controlText()) + this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) + this.panelChildEl.appendChild(this.settingsSubMenuEl_) + this.update() + + this.createBackButton() + this.setSize() + this.bindClickEvents() + + // prefixed event listeners for CSS TransitionEnd + this.PrefixedEvent( + this.settingsSubMenuEl_, + 'TransitionEnd', + this.transitionEndHandler, + 'addEvent' + ) + } + + update (event?: any) { + let target: HTMLElement = null + const 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') { + const html = (this.subMenu as any).labelEl_.innerHTML + + setTimeout(() => { + this.settingsSubMenuValueEl_.innerHTML = html + }, 250) + } else { + // Loop trough the submenu items to find the selected child + for (const subMenuItem of this.subMenu.menu.children_) { + if (!(subMenuItem instanceof component)) { + continue + } + + if (subMenuItem.hasClass('vjs-selected')) { + const subMenuItemUntyped = subMenuItem as any + + // Prefer to use the function + if (typeof subMenuItemUntyped.getLabel === 'function') { + this.settingsSubMenuValueEl_.innerHTML = subMenuItemUntyped.getLabel() + break + } + + this.settingsSubMenuValueEl_.innerHTML = this.player().localize(subMenuItemUntyped.options_.label) + } + } + } + + if (target && !target.classList.contains('vjs-back-button')) { + this.settingsButton.hideDialog() + } + } + + bindClickEvents () { + for (const 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 + setSize () { + this.dialog.removeClass('vjs-hidden') + videojs.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') + this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) + this.setMargin() + this.dialog.addClass('vjs-hidden') + videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + } + + setMargin () { + if (!this.size) return + + const [ 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 (videojs.dom.hasClass(this.el(), 'open')) { + videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') + videojs.dom.removeClass(this.el(), 'open') + } + } + +} + +(SettingsMenuItem as any).prototype.contentElType = 'button' +videojs.registerComponent('SettingsMenuItem', SettingsMenuItem) + +export { SettingsMenuItem } diff --git a/client/src/assets/player/shared/settings/settings-panel-child.ts b/client/src/assets/player/shared/settings/settings-panel-child.ts new file mode 100644 index 000000000..161420c38 --- /dev/null +++ b/client/src/assets/player/shared/settings/settings-panel-child.ts @@ -0,0 +1,18 @@ +import videojs from 'video.js' + +const Component = videojs.getComponent('Component') + +class SettingsPanelChild extends Component { + + createEl () { + return super.createEl('div', { + className: 'vjs-settings-panel-child', + innerHTML: '', + tabIndex: -1 + }) + } +} + +Component.registerComponent('SettingsPanelChild', SettingsPanelChild) + +export { SettingsPanelChild } diff --git a/client/src/assets/player/shared/settings/settings-panel.ts b/client/src/assets/player/shared/settings/settings-panel.ts new file mode 100644 index 000000000..28b579bdd --- /dev/null +++ b/client/src/assets/player/shared/settings/settings-panel.ts @@ -0,0 +1,18 @@ +import videojs from 'video.js' + +const Component = videojs.getComponent('Component') + +class SettingsPanel extends Component { + + createEl () { + return super.createEl('div', { + className: 'vjs-settings-panel', + innerHTML: '', + tabIndex: -1 + }) + } +} + +Component.registerComponent('SettingsPanel', SettingsPanel) + +export { SettingsPanel } diff --git a/client/src/assets/player/shared/stats/index.ts b/client/src/assets/player/shared/stats/index.ts new file mode 100644 index 000000000..017ec044c --- /dev/null +++ b/client/src/assets/player/shared/stats/index.ts @@ -0,0 +1,2 @@ +export * from './stats-card' +export * from './stats-plugin' diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts new file mode 100644 index 000000000..1bf631d2c --- /dev/null +++ b/client/src/assets/player/shared/stats/stats-card.ts @@ -0,0 +1,271 @@ +import videojs from 'video.js' +import { secondsToTime } from '@shared/core-utils' +import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../../types' +import { bytes } from '../common' + +interface StatsCardOptions extends videojs.ComponentOptions { + videoUUID: string + videoIsLive: boolean + mode: 'webtorrent' | 'p2p-media-loader' + p2pEnabled: boolean +} + +interface PlayerNetworkInfo { + downloadSpeed?: string + uploadSpeed?: string + totalDownloaded?: string + totalUploaded?: string + numPeers?: number + averageBandwidth?: string + + downloadedFromServer?: string + downloadedFromPeers?: string +} + +const Component = videojs.getComponent('Component') +class StatsCard extends Component { + options_: StatsCardOptions + + container: HTMLDivElement + + list: HTMLDivElement + closeButton: HTMLElement + + updateInterval: any + + mode: 'webtorrent' | 'p2p-media-loader' + + metadataStore: any = {} + + intervalMs = 300 + playerNetworkInfo: PlayerNetworkInfo = {} + + createEl () { + const container = super.createEl('div', { + className: 'vjs-stats-content', + innerHTML: this.getMainTemplate() + }) as HTMLDivElement + this.container = container + this.container.style.display = 'none' + + this.closeButton = this.container.getElementsByClassName('vjs-stats-close')[0] as HTMLElement + this.closeButton.onclick = () => this.hide() + + this.list = this.container.getElementsByClassName('vjs-stats-list')[0] as HTMLDivElement + + this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { + if (!data) return // HTTP fallback + + this.mode = data.source + + const p2pStats = data.p2p + const httpStats = data.http + + this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ') + this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed).join(' ') + this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ') + this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ') + this.playerNetworkInfo.numPeers = p2pStats.numPeers + this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s' + + if (data.source === 'p2p-media-loader') { + this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') + this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') + } + }) + + return container + } + + toggle () { + if (this.updateInterval) this.hide() + else this.show() + } + + show () { + this.container.style.display = 'block' + this.updateInterval = setInterval(async () => { + try { + const options = this.mode === 'p2p-media-loader' + ? this.buildHLSOptions() + : await this.buildWebTorrentOptions() // Default + + this.list.innerHTML = this.getListTemplate(options) + } catch (err) { + console.error('Cannot update stats.', err) + clearInterval(this.updateInterval) + } + }, this.intervalMs) + } + + hide () { + clearInterval(this.updateInterval) + this.container.style.display = 'none' + } + + private buildHLSOptions () { + const p2pMediaLoader = this.player_.p2pMediaLoader() + const level = p2pMediaLoader.getCurrentLevel() + + const codecs = level?.videoCodec || level?.audioCodec + ? `${level?.videoCodec || ''} / ${level?.audioCodec || ''}` + : undefined + + const resolution = `${level?.height}p${level?.attrs['FRAME-RATE'] || ''}` + const buffer = this.timeRangesToString(this.player().buffered()) + + let progress: number + let latency: string + + if (this.options_.videoIsLive) { + latency = secondsToTime(p2pMediaLoader.getLiveLatency()) + } else { + progress = this.player().bufferedPercent() + } + + return { + playerNetworkInfo: this.playerNetworkInfo, + resolution, + codecs, + buffer, + latency, + progress + } + } + + private async buildWebTorrentOptions () { + const videoFile = this.player_.webtorrent().getCurrentVideoFile() + + if (!this.metadataStore[videoFile.fileUrl]) { + this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) + } + + const metadata = this.metadataStore[videoFile.fileUrl] + + let colorSpace = 'unknown' + let codecs = 'unknown' + + if (metadata?.streams[0]) { + const stream = metadata.streams[0] + + colorSpace = stream['color_space'] !== 'unknown' + ? stream['color_space'] + : 'bt709' + + codecs = stream['codec_name'] || 'avc1' + } + + const resolution = videoFile?.resolution.label + videoFile?.fps + const buffer = this.timeRangesToString(this.player().buffered()) + const progress = this.player_.webtorrent().getTorrent()?.progress + + return { + playerNetworkInfo: this.playerNetworkInfo, + progress, + colorSpace, + codecs, + resolution, + buffer + } + } + + private getListTemplate (options: { + playerNetworkInfo: PlayerNetworkInfo + progress: number + codecs: string + resolution: string + buffer: string + + latency?: string + colorSpace?: string + }) { + const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options + const player = this.player() + + const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality() + const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) + const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) + const pr = (window.devicePixelRatio || 1).toFixed(2) + const frames = `${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}` + + const duration = player.duration() + + let volume = `${Math.round(player.volume() * 100)}` + if (player.muted()) volume += ' (muted)' + + const networkActivity = playerNetworkInfo.downloadSpeed + ? `${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑` + : undefined + + const totalTransferred = playerNetworkInfo.totalDownloaded + ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑` + : undefined + const downloadBreakdown = playerNetworkInfo.downloadedFromServer + ? `${playerNetworkInfo.downloadedFromServer} from servers ยท ${playerNetworkInfo.downloadedFromPeers} from peers` + : undefined + + const bufferProgress = progress !== undefined + ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` + : undefined + + return ` + ${this.buildElement(player.localize('Player mode'), this.mode || 'HTTP')} + ${this.buildElement(player.localize('P2P'), player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled'))} + + ${this.buildElement(player.localize('Video UUID'), this.options_.videoUUID)} + + ${this.buildElement(player.localize('Viewport / Frames'), frames)} + + ${this.buildElement(player.localize('Resolution'), resolution)} + + ${this.buildElement(player.localize('Volume'), volume)} + + ${this.buildElement(player.localize('Codecs'), codecs)} + ${this.buildElement(player.localize('Color'), colorSpace)} + + ${this.buildElement(player.localize('Connection Speed'), playerNetworkInfo.averageBandwidth)} + + ${this.buildElement(player.localize('Network Activity'), networkActivity)} + ${this.buildElement(player.localize('Total Transfered'), totalTransferred)} + ${this.buildElement(player.localize('Download Breakdown'), downloadBreakdown)} + + ${this.buildElement(player.localize('Buffer Progress'), bufferProgress)} + ${this.buildElement(player.localize('Buffer State'), buffer)} + + ${this.buildElement(player.localize('Live Latency'), latency)} + ` + } + + private getMainTemplate () { + return ` + +
+ ` + } + + private buildElement (label: string, value?: string) { + if (!value) return '' + + return `
${label}
${value}
` + } + + private timeRangesToString (r: videojs.TimeRange) { + let result = '' + + for (let i = 0; i < r.length; i++) { + const start = Math.floor(r.start(i)) + const end = Math.floor(r.end(i)) + + result += `[${secondsToTime(start)}, ${secondsToTime(end)}] ` + } + + return result + } +} + +videojs.registerComponent('StatsCard', StatsCard) + +export { + StatsCard, + StatsCardOptions +} diff --git a/client/src/assets/player/shared/stats/stats-plugin.ts b/client/src/assets/player/shared/stats/stats-plugin.ts new file mode 100644 index 000000000..8aad80e8a --- /dev/null +++ b/client/src/assets/player/shared/stats/stats-plugin.ts @@ -0,0 +1,31 @@ +import videojs from 'video.js' +import { StatsCard, StatsCardOptions } from './stats-card' + +const Plugin = videojs.getPlugin('plugin') + +class StatsForNerdsPlugin extends Plugin { + private statsCard: StatsCard + + constructor (player: videojs.Player, options: StatsCardOptions) { + const settings = { + ...options + } + + super(player) + + this.player.ready(() => { + player.addClass('vjs-stats-for-nerds') + }) + + this.statsCard = new StatsCard(player, options) + + player.addChild(this.statsCard, settings) + } + + show () { + this.statsCard.show() + } +} + +videojs.registerPlugin('stats', StatsForNerdsPlugin) +export { StatsForNerdsPlugin } diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts new file mode 100644 index 000000000..61668e407 --- /dev/null +++ b/client/src/assets/player/shared/upnext/end-card.ts @@ -0,0 +1,157 @@ +import videojs from 'video.js' + +function getMainTemplate (options: any) { + return ` +
+ ${options.headText} +
+
+
+ + + + +
+ + + + + ${options.suspendedText} + + ` +} + +export interface EndCardOptions extends videojs.ComponentOptions { + next: () => void + getTitle: () => string + timeout: number + cancelText: string + headText: string + suspendedText: string + condition: () => boolean + suspended: () => boolean +} + +const Component = videojs.getComponent('Component') +class EndCard extends Component { + options_: EndCardOptions + + dashOffsetTotal = 586 + dashOffsetStart = 293 + interval = 50 + upNextEvents = new videojs.EventTarget() + ticks = 0 + totalTicks: number + + container: HTMLDivElement + title: HTMLElement + autoplayRing: HTMLElement + cancelButton: HTMLElement + suspendedMessage: HTMLElement + nextButton: HTMLElement + + constructor (player: videojs.Player, options: EndCardOptions) { + super(player, options) + + this.totalTicks = this.options_.timeout / this.interval + + player.on('ended', (_: any) => { + if (!this.options_.condition()) return + + player.addClass('vjs-upnext--showing') + this.showCard((canceled: boolean) => { + player.removeClass('vjs-upnext--showing') + this.container.style.display = 'none' + if (!canceled) { + this.options_.next() + } + }) + }) + + player.on('playing', () => { + this.upNextEvents.trigger('playing') + }) + } + + createEl () { + const container = super.createEl('div', { + className: 'vjs-upnext-content', + innerHTML: getMainTemplate(this.options_) + }) as HTMLDivElement + + this.container = container + container.style.display = 'none' + + this.autoplayRing = container.getElementsByClassName('vjs-upnext-svg-autoplay-ring')[0] as HTMLElement + this.title = container.getElementsByClassName('vjs-upnext-title')[0] as HTMLElement + this.cancelButton = container.getElementsByClassName('vjs-upnext-cancel-button')[0] as HTMLElement + this.suspendedMessage = container.getElementsByClassName('vjs-upnext-suspended')[0] as HTMLElement + this.nextButton = container.getElementsByClassName('vjs-upnext-autoplay-icon')[0] as HTMLElement + + this.cancelButton.onclick = () => { + this.upNextEvents.trigger('cancel') + } + + this.nextButton.onclick = () => { + this.upNextEvents.trigger('next') + } + + return container + } + + showCard (cb: (value: boolean) => void) { + let timeout: any + + this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) + this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`) + + this.title.innerHTML = this.options_.getTitle() + + this.upNextEvents.one('cancel', () => { + clearTimeout(timeout) + cb(true) + }) + + this.upNextEvents.one('playing', () => { + clearTimeout(timeout) + cb(true) + }) + + this.upNextEvents.one('next', () => { + clearTimeout(timeout) + cb(false) + }) + + const goToPercent = (percent: number) => { + const newOffset = Math.max(-this.dashOffsetTotal, -this.dashOffsetStart - percent * this.dashOffsetTotal / 2 / 100) + this.autoplayRing.setAttribute('stroke-dashoffset', '' + newOffset) + } + + const tick = () => { + goToPercent((this.ticks++) * 100 / this.totalTicks) + } + + const update = () => { + if (this.options_.suspended()) { + this.suspendedMessage.innerText = this.options_.suspendedText + goToPercent(0) + this.ticks = 0 + timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer + } else if (this.ticks >= this.totalTicks) { + clearTimeout(timeout) + cb(false) + } else { + this.suspendedMessage.innerText = '' + tick() + timeout = setTimeout(update.bind(this), this.interval) + } + } + + this.container.style.display = 'block' + timeout = setTimeout(update.bind(this), this.interval) + } +} + +videojs.registerComponent('EndCard', EndCard) diff --git a/client/src/assets/player/shared/upnext/index.ts b/client/src/assets/player/shared/upnext/index.ts new file mode 100644 index 000000000..c63c5fd83 --- /dev/null +++ b/client/src/assets/player/shared/upnext/index.ts @@ -0,0 +1,2 @@ +export * from './end-card' +export * from './upnext-plugin' diff --git a/client/src/assets/player/shared/upnext/upnext-plugin.ts b/client/src/assets/player/shared/upnext/upnext-plugin.ts new file mode 100644 index 000000000..db969024d --- /dev/null +++ b/client/src/assets/player/shared/upnext/upnext-plugin.ts @@ -0,0 +1,31 @@ +import videojs from 'video.js' +import { EndCardOptions } from './end-card' + +const Plugin = videojs.getPlugin('plugin') + +class UpNextPlugin extends Plugin { + + constructor (player: videojs.Player, options: Partial = {}) { + const settings = { + next: options.next, + getTitle: options.getTitle, + timeout: options.timeout || 5000, + cancelText: options.cancelText || 'Cancel', + headText: options.headText || 'Up Next', + suspendedText: options.suspendedText || 'Autoplay is suspended', + condition: options.condition, + suspended: options.suspended + } + + super(player) + + this.player.ready(() => { + player.addClass('vjs-upnext') + }) + + player.addChild('EndCard', settings) + } +} + +videojs.registerPlugin('upnext', UpNextPlugin) +export { UpNextPlugin } diff --git a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts new file mode 100644 index 000000000..81378c277 --- /dev/null +++ b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts @@ -0,0 +1,233 @@ +// From https://github.com/MinEduTDF/idb-chunk-store +// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues +// Thanks @santiagogil and @Feross + +import { EventEmitter } from 'events' +import Dexie from 'dexie' + +class ChunkDatabase extends Dexie { + chunks: Dexie.Table<{ id: number, buf: Buffer }, number> + + constructor (dbname: string) { + super(dbname) + + this.version(1).stores({ + chunks: 'id' + }) + } +} + +class ExpirationDatabase extends Dexie { + databases: Dexie.Table<{ name: string, expiration: number }, number> + + constructor () { + super('webtorrent-expiration') + + this.version(1).stores({ + databases: 'name,expiration' + }) + } +} + +export class PeertubeChunkStore extends EventEmitter { + private static readonly BUFFERING_PUT_MS = 1000 + private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute + private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes + + chunkLength: number + + private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = [] + // If the store is full + private memoryChunks: { [ id: number ]: Buffer | true } = {} + private databaseName: string + private putBulkTimeout: any + private cleanerInterval: any + private db: ChunkDatabase + private expirationDB: ExpirationDatabase + private readonly length: number + private readonly lastChunkLength: number + private readonly lastChunkIndex: number + + constructor (chunkLength: number, opts: any) { + super() + + this.databaseName = 'webtorrent-chunks-' + + if (!opts) opts = {} + if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash + else this.databaseName += '-default' + + this.setMaxListeners(100) + + this.chunkLength = Number(chunkLength) + if (!this.chunkLength) throw new Error('First argument must be a chunk length') + + this.length = Number(opts.length) || Infinity + + if (this.length !== Infinity) { + this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength + this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 + } + + this.db = new ChunkDatabase(this.databaseName) + // Track databases that expired + this.expirationDB = new ExpirationDatabase() + + this.runCleaner() + } + + put (index: number, buf: Buffer, cb: (err?: Error) => void) { + const isLastChunk = (index === this.lastChunkIndex) + if (isLastChunk && buf.length !== this.lastChunkLength) { + return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) + } + if (!isLastChunk && buf.length !== this.chunkLength) { + return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) + } + + // Specify we have this chunk + this.memoryChunks[index] = true + + // Add it to the pending put + this.pendingPut.push({ id: index, buf, cb }) + // If it's already planned, return + if (this.putBulkTimeout) return + + // Plan a future bulk insert + this.putBulkTimeout = setTimeout(async () => { + const processing = this.pendingPut + this.pendingPut = [] + this.putBulkTimeout = undefined + + try { + await this.db.transaction('rw', this.db.chunks, () => { + return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) + }) + } catch (err) { + console.log('Cannot bulk insert chunks. Store them in memory.', { err }) + + processing.forEach(p => { + this.memoryChunks[p.id] = p.buf + }) + } finally { + processing.forEach(p => p.cb()) + } + }, PeertubeChunkStore.BUFFERING_PUT_MS) + } + + get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { + if (typeof opts === 'function') return this.get(index, null, opts) + + // IndexDB could be slow, use our memory index first + const memoryChunk = this.memoryChunks[index] + if (memoryChunk === undefined) { + const err = new Error('Chunk not found') as any + err['notFound'] = true + + return process.nextTick(() => cb(err)) + } + + // Chunk in memory + if (memoryChunk !== true) return cb(null, memoryChunk) + + // Chunk in store + this.db.transaction('r', this.db.chunks, async () => { + const result = await this.db.chunks.get({ id: index }) + if (result === undefined) return cb(null, Buffer.alloc(0)) + + const buf = result.buf + if (!opts) return this.nextTick(cb, null, buf) + + const offset = opts.offset || 0 + const len = opts.length || (buf.length - offset) + return cb(null, buf.slice(offset, len + offset)) + }) + .catch(err => { + console.error(err) + return cb(err) + }) + } + + close (cb: (err?: Error) => void) { + return this.destroy(cb) + } + + async destroy (cb: (err?: Error) => void) { + try { + if (this.pendingPut) { + clearTimeout(this.putBulkTimeout) + this.pendingPut = null + } + if (this.cleanerInterval) { + clearInterval(this.cleanerInterval) + this.cleanerInterval = null + } + + if (this.db) { + this.db.close() + + await this.dropDatabase(this.databaseName) + } + + if (this.expirationDB) { + this.expirationDB.close() + this.expirationDB = null + } + + return cb() + } catch (err) { + console.error('Cannot destroy peertube chunk store.', err) + return cb(err) + } + } + + private runCleaner () { + this.checkExpiration() + + this.cleanerInterval = setInterval(() => { + this.checkExpiration() + }, PeertubeChunkStore.CLEANER_INTERVAL_MS) + } + + private async checkExpiration () { + let databasesToDeleteInfo: { name: string }[] = [] + + try { + await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { + // Update our database expiration since we are alive + await this.expirationDB.databases.put({ + name: this.databaseName, + expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS + }) + + const now = new Date().getTime() + databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() + }) + } catch (err) { + console.error('Cannot update expiration of fetch expired databases.', err) + } + + for (const databaseToDeleteInfo of databasesToDeleteInfo) { + await this.dropDatabase(databaseToDeleteInfo.name) + } + } + + private async dropDatabase (databaseName: string) { + const dbToDelete = new ChunkDatabase(databaseName) + console.log('Destroying IndexDB database %s.', databaseName) + + try { + await dbToDelete.delete() + + await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { + return this.expirationDB.databases.where({ name: databaseName }).delete() + }) + } catch (err) { + console.error('Cannot delete %s.', databaseName, err) + } + } + + private nextTick (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { + process.nextTick(() => cb(err, val), undefined) + } +} diff --git a/client/src/assets/player/shared/webtorrent/video-renderer.ts b/client/src/assets/player/shared/webtorrent/video-renderer.ts new file mode 100644 index 000000000..9b80fea2c --- /dev/null +++ b/client/src/assets/player/shared/webtorrent/video-renderer.ts @@ -0,0 +1,133 @@ +// Thanks: https://github.com/feross/render-media + +const MediaElementWrapper = require('mediasource') +import { extname } from 'path' +const Videostream = require('videostream') + +const VIDEOSTREAM_EXTS = [ + '.m4a', + '.m4v', + '.mp4' +] + +type RenderMediaOptions = { + controls: boolean + autoplay: boolean +} + +function renderVideo ( + file: any, + elem: HTMLVideoElement, + opts: RenderMediaOptions, + callback: (err: Error, renderer: any) => void +) { + validateFile(file) + + return renderMedia(file, elem, opts, callback) +} + +function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { + const extension = extname(file.name).toLowerCase() + let preparedElem: any + let currentTime = 0 + let renderer: any + + try { + if (VIDEOSTREAM_EXTS.includes(extension)) { + renderer = useVideostream() + } else { + renderer = useMediaSource() + } + } catch (err) { + return callback(err) + } + + function useVideostream () { + prepareElem() + preparedElem.addEventListener('error', function onError (err: Error) { + preparedElem.removeEventListener('error', onError) + + return callback(err) + }) + preparedElem.addEventListener('loadstart', onLoadStart) + return new Videostream(file, preparedElem) + } + + function useMediaSource (useVP9 = false) { + const codecs = getCodec(file.name, useVP9) + + prepareElem() + preparedElem.addEventListener('error', function onError (err: Error) { + preparedElem.removeEventListener('error', onError) + + // Try with vp9 before returning an error + if (codecs.includes('vp8')) return fallbackToMediaSource(true) + + return callback(err) + }) + preparedElem.addEventListener('loadstart', onLoadStart) + + const wrapper = new MediaElementWrapper(preparedElem) + const writable = wrapper.createWriteStream(codecs) + file.createReadStream().pipe(writable) + + if (currentTime) preparedElem.currentTime = currentTime + + return wrapper + } + + function fallbackToMediaSource (useVP9 = false) { + if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.') + else console.log('Falling back to media source..') + + useMediaSource(useVP9) + } + + function prepareElem () { + if (preparedElem === undefined) { + preparedElem = elem + + preparedElem.addEventListener('progress', function () { + currentTime = elem.currentTime + }) + } + } + + function onLoadStart () { + preparedElem.removeEventListener('loadstart', onLoadStart) + if (opts.autoplay) preparedElem.play() + + callback(null, renderer) + } +} + +function validateFile (file: any) { + if (file == null) { + throw new Error('file cannot be null or undefined') + } + if (typeof file.name !== 'string') { + throw new Error('missing or invalid file.name property') + } + if (typeof file.createReadStream !== 'function') { + throw new Error('missing or invalid file.createReadStream property') + } +} + +function getCodec (name: string, useVP9 = false) { + const ext = extname(name).toLowerCase() + if (ext === '.mp4') { + return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' + } + + if (ext === '.webm') { + if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' + + return 'video/webm; codecs="vp8, vorbis"' + } + + return undefined +} + +export { + renderVideo +} diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts new file mode 100644 index 000000000..b48203148 --- /dev/null +++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts @@ -0,0 +1,641 @@ +import videojs from 'video.js' +import * as WebTorrent from 'webtorrent' +import { isIOS } from '@root-helpers/web-browser' +import { timeToInt } from '@shared/core-utils' +import { VideoFile } from '@shared/models' +import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage' +import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types' +import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common' +import { PeertubeChunkStore } from './peertube-chunk-store' +import { renderVideo } from './video-renderer' + +const CacheChunkStore = require('cache-chunk-store') + +type PlayOptions = { + forcePlay?: boolean + seek?: number + delay?: number +} + +const Plugin = videojs.getPlugin('plugin') + +class WebTorrentPlugin extends Plugin { + readonly videoFiles: VideoFile[] + + private readonly playerElement: HTMLVideoElement + + private readonly autoplay: boolean = false + private readonly startTime: number = 0 + private readonly savePlayerSrcFunction: videojs.Player['src'] + private readonly videoDuration: number + private readonly CONSTANTS = { + INFO_SCHEDULER: 1000, // Don't change this + AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds + AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it + AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check + AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds + BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth + } + + private readonly webtorrent = new WebTorrent({ + tracker: { + rtcConfig: getRtcConfig() + }, + dht: false + }) + + private currentVideoFile: VideoFile + private torrent: WebTorrent.Torrent + + private renderer: any + private fakeRenderer: any + private destroyingFakeRenderer = false + + private autoResolution = true + private autoResolutionPossible = true + private isAutoResolutionObservation = false + private playerRefusedP2P = false + + private torrentInfoInterval: any + private autoQualityInterval: any + private addTorrentDelay: any + private qualityObservationTimer: any + private runAutoQualitySchedulerTimer: any + + private downloadSpeeds: number[] = [] + + constructor (player: videojs.Player, options?: WebtorrentPluginOptions) { + super(player) + + this.startTime = timeToInt(options.startTime) + + // Custom autoplay handled by webtorrent because we lazy play the video + this.autoplay = options.autoplay + + this.playerRefusedP2P = options.playerRefusedP2P + + this.videoFiles = options.videoFiles + this.videoDuration = options.videoDuration + + this.savePlayerSrcFunction = this.player.src + this.playerElement = options.playerElement + + this.player.ready(() => { + const playerOptions = this.player.options_ + + const volume = getStoredVolume() + if (volume !== undefined) this.player.volume(volume) + + const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() + if (muted !== undefined) this.player.muted(muted) + + this.player.duration(options.videoDuration) + + this.initializePlayer() + this.runTorrentInfoScheduler() + + this.player.one('play', () => { + // Don't run immediately scheduler, wait some seconds the TCP connections are made + this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) + }) + }) + } + + dispose () { + clearTimeout(this.addTorrentDelay) + clearTimeout(this.qualityObservationTimer) + clearTimeout(this.runAutoQualitySchedulerTimer) + + clearInterval(this.torrentInfoInterval) + clearInterval(this.autoQualityInterval) + + // Don't need to destroy renderer, video player will be destroyed + this.flushVideoFile(this.currentVideoFile, false) + + this.destroyFakeRenderer() + } + + getCurrentResolutionId () { + return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 + } + + updateVideoFile ( + videoFile?: VideoFile, + options: { + forcePlay?: boolean + seek?: number + delay?: number + } = {}, + done: () => void = () => { /* empty */ } + ) { + // Automatically choose the adapted video file + if (!videoFile) { + const savedAverageBandwidth = getAverageBandwidthInStore() + videoFile = savedAverageBandwidth + ? this.getAppropriateFile(savedAverageBandwidth) + : this.pickAverageVideoFile() + } + + if (!videoFile) { + throw Error(`Can't update video file since videoFile is undefined.`) + } + + // Don't add the same video file once again + if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { + return + } + + // Do not display error to user because we will have multiple fallback + this.player.peertube().hideFatalError(); + + // Hack to "simulate" src link in video.js >= 6 + // Without this, we can't play the video after pausing it + // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 + (this.player as any).src = () => true + const oldPlaybackRate = this.player.playbackRate() + + const previousVideoFile = this.currentVideoFile + this.currentVideoFile = videoFile + + // Don't try on iOS that does not support MediaSource + // Or don't use P2P if webtorrent is disabled + if (isIOS() || this.playerRefusedP2P) { + return this.fallbackToHttp(options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + } + + this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { + this.player.playbackRate(oldPlaybackRate) + return done() + }) + + this.selectAppropriateResolution(true) + } + + updateEngineResolution (resolutionId: number, delay = 0) { + // Remember player state + const currentTime = this.player.currentTime() + const isPaused = this.player.paused() + + // Hide bigPlayButton + if (!isPaused) { + this.player.bigPlayButton.hide() + } + + // Audio-only (resolutionId === 0) gets special treatment + if (resolutionId === 0) { + // Audio-only: show poster, do not auto-hide controls + this.player.addClass('vjs-playing-audio-only-content') + this.player.posterImage.show() + } else { + // Hide poster to have black background + this.player.removeClass('vjs-playing-audio-only-content') + this.player.posterImage.hide() + } + + const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) + const options = { + forcePlay: false, + delay, + seek: currentTime + (delay / 1000) + } + + this.updateVideoFile(newVideoFile, options) + } + + flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { + if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { + if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() + + this.webtorrent.remove(videoFile.magnetUri) + console.log('Removed ' + videoFile.magnetUri) + } + } + + disableAutoResolution () { + this.autoResolution = false + this.autoResolutionPossible = false + this.player.peertubeResolutions().disableAutoResolution() + } + + isAutoResolutionPossible () { + return this.autoResolutionPossible + } + + getTorrent () { + return this.torrent + } + + getCurrentVideoFile () { + return this.currentVideoFile + } + + changeQuality (id: number) { + if (id === -1) { + if (this.autoResolutionPossible === true) { + this.autoResolution = true + + this.selectAppropriateResolution(false) + } + + return + } + + this.autoResolution = false + this.updateEngineResolution(id) + this.selectAppropriateResolution(false) + } + + private addTorrent ( + magnetOrTorrentUrl: string, + previousVideoFile: VideoFile, + options: PlayOptions, + done: (err?: Error) => void + ) { + if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done) + + console.log('Adding ' + magnetOrTorrentUrl + '.') + + const oldTorrent = this.torrent + const torrentOptions = { + // Don't use arrow function: it breaks webtorrent (that uses `new` keyword) + store: function (chunkLength: number, storeOpts: any) { + return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { + max: 100 + }) + } + } + + this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { + console.log('Added ' + magnetOrTorrentUrl + '.') + + if (oldTorrent) { + // Pause the old torrent + this.stopTorrent(oldTorrent) + + // We use a fake renderer so we download correct pieces of the next file + if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay) + } + + // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) + this.addTorrentDelay = setTimeout(() => { + // We don't need the fake renderer anymore + this.destroyFakeRenderer() + + const paused = this.player.paused() + + this.flushVideoFile(previousVideoFile) + + // Update progress bar (just for the UI), do not wait rendering + if (options.seek) this.player.currentTime(options.seek) + + const renderVideoOptions = { autoplay: false, controls: true } + renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => { + this.renderer = renderer + + if (err) return this.fallbackToHttp(options, done) + + return this.tryToPlay(err => { + if (err) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + return done() + }) + }) + }, options.delay || 0) + }) + + this.torrent.on('error', (err: any) => console.error(err)) + + this.torrent.on('warning', (err: any) => { + // We don't support HTTP tracker but we don't care -> we use the web socket tracker + if (err.message.indexOf('Unsupported tracker protocol') !== -1) return + + // Users don't care about issues with WebRTC, but developers do so log it in the console + if (err.message.indexOf('Ice connection failed') !== -1) { + console.log(err) + return + } + + // Magnet hash is not up to date with the torrent file, add directly the torrent file + if (err.message.indexOf('incorrect info hash') !== -1) { + console.error('Incorrect info hash detected, falling back to torrent file.') + const newOptions = { forcePlay: true, seek: options.seek } + return this.addTorrent(this.torrent['xs'], previousVideoFile, newOptions, done) + } + + // Remote instance is down + if (err.message.indexOf('from xs param') !== -1) { + this.handleError(err) + } + + console.warn(err) + }) + } + + private tryToPlay (done?: (err?: Error) => void) { + if (!done) done = function () { /* empty */ } + + const playPromise = this.player.play() + if (playPromise !== undefined) { + return playPromise.then(() => done()) + .catch((err: Error) => { + if (err.message.includes('The play() request was interrupted by a call to pause()')) { + return + } + + console.error(err) + this.player.pause() + this.player.posterImage.show() + this.player.removeClass('vjs-has-autoplay') + this.player.removeClass('vjs-has-big-play-button-clicked') + this.player.removeClass('vjs-playing-audio-only-content') + + return done() + }) + } + + return done() + } + + private seek (time: number) { + this.player.currentTime(time) + this.player.handleTechSeeked_() + } + + private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { + if (this.videoFiles === undefined) return undefined + if (this.videoFiles.length === 1) return this.videoFiles[0] + + const files = this.videoFiles.filter(f => f.resolution.id !== 0) + if (files.length === 0) return undefined + + // Don't change the torrent if the player ended + if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile + + if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() + + // Limit resolution according to player height + const playerHeight = this.playerElement.offsetHeight + + // We take the first resolution just above the player height + // Example: player height is 530px, we want the 720p file instead of 480p + let maxResolution = files[0].resolution.id + for (let i = files.length - 1; i >= 0; i--) { + const resolutionId = files[i].resolution.id + if (resolutionId !== 0 && resolutionId >= playerHeight) { + maxResolution = resolutionId + break + } + } + + // Filter videos we can play according to our screen resolution and bandwidth + const filteredFiles = files.filter(f => f.resolution.id <= maxResolution) + .filter(f => { + const fileBitrate = (f.size / this.videoDuration) + let threshold = fileBitrate + + // If this is for a higher resolution or an initial load: add a margin + if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { + threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) + } + + return averageDownloadSpeed > threshold + }) + + // If the download speed is too bad, return the lowest resolution we have + if (filteredFiles.length === 0) return videoFileMinByResolution(files) + + return videoFileMaxByResolution(filteredFiles) + } + + private getAndSaveActualDownloadSpeed () { + const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) + const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) + if (lastDownloadSpeeds.length === 0) return -1 + + const sum = lastDownloadSpeeds.reduce((a, b) => a + b) + const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) + + // Save the average bandwidth for future use + saveAverageBandwidth(averageBandwidth) + + return averageBandwidth + } + + private initializePlayer () { + this.buildQualities() + + if (this.autoplay) { + this.player.posterImage.hide() + + return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) + } + + // Proxy first play + const oldPlay = this.player.play.bind(this.player); + (this.player as any).play = () => { + this.player.addClass('vjs-has-big-play-button-clicked') + this.player.play = oldPlay + + this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) + } + } + + private runAutoQualityScheduler () { + this.autoQualityInterval = setInterval(() => { + + // Not initialized or in HTTP fallback + if (this.torrent === undefined || this.torrent === null) return + if (this.autoResolution === false) return + if (this.isAutoResolutionObservation === true) return + + const file = this.getAppropriateFile() + let changeResolution = false + let changeResolutionDelay = 0 + + // Lower resolution + if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { + console.log('Downgrading automatically the resolution to: %s', file.resolution.label) + changeResolution = true + } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution + console.log('Upgrading automatically the resolution to: %s', file.resolution.label) + changeResolution = true + changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY + } + + if (changeResolution === true) { + this.updateEngineResolution(file.resolution.id, changeResolutionDelay) + + // Wait some seconds in observation of our new resolution + this.isAutoResolutionObservation = true + + this.qualityObservationTimer = setTimeout(() => { + this.isAutoResolutionObservation = false + }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) + } + }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) + } + + private isPlayerWaiting () { + return this.player?.hasClass('vjs-waiting') + } + + private runTorrentInfoScheduler () { + this.torrentInfoInterval = setInterval(() => { + // Not initialized yet + if (this.torrent === undefined) return + + // Http fallback + if (this.torrent === null) return this.player.trigger('p2pInfo', false) + + // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too + if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) + + return this.player.trigger('p2pInfo', { + source: 'webtorrent', + http: { + downloadSpeed: 0, + uploadSpeed: 0, + downloaded: 0, + uploaded: 0 + }, + p2p: { + downloadSpeed: this.torrent.downloadSpeed, + numPeers: this.torrent.numPeers, + uploadSpeed: this.torrent.uploadSpeed, + downloaded: this.torrent.downloaded, + uploaded: this.torrent.uploaded + }, + bandwidthEstimate: this.webtorrent.downloadSpeed + } as PlayerNetworkInfo) + }, this.CONSTANTS.INFO_SCHEDULER) + } + + private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) { + const paused = this.player.paused() + + this.disableAutoResolution() + + this.flushVideoFile(this.currentVideoFile, true) + this.torrent = null + + // Enable error display now this is our last fallback + this.player.one('error', () => this.player.peertube().displayFatalError()) + + const httpUrl = this.currentVideoFile.fileUrl + this.player.src = this.savePlayerSrcFunction + this.player.src(httpUrl) + + this.selectAppropriateResolution(true) + + // We changed the source, so reinit captions + this.player.trigger('sourcechange') + + return this.tryToPlay(err => { + if (err && done) return done(err) + + if (options.seek) this.seek(options.seek) + if (options.forcePlay === false && paused === true) this.player.pause() + + if (done) return done() + }) + } + + private handleError (err: Error | string) { + return this.player.trigger('customError', { err }) + } + + private pickAverageVideoFile () { + if (this.videoFiles.length === 1) return this.videoFiles[0] + + const files = this.videoFiles.filter(f => f.resolution.id !== 0) + return files[Math.floor(files.length / 2)] + } + + private stopTorrent (torrent: WebTorrent.Torrent) { + torrent.pause() + // Pause does not remove actual peers (in particular the webseed peer) + torrent.removePeer(torrent['ws']) + } + + private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { + this.destroyingFakeRenderer = false + + const fakeVideoElem = document.createElement('video') + renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { + this.fakeRenderer = renderer + + // The renderer returns an error when we destroy it, so skip them + if (this.destroyingFakeRenderer === false && err) { + console.error('Cannot render new torrent in fake video element.', err) + } + + // Load the future file at the correct time (in delay MS - 2 seconds) + fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) + }) + } + + private destroyFakeRenderer () { + if (this.fakeRenderer) { + this.destroyingFakeRenderer = true + + if (this.fakeRenderer.destroy) { + try { + this.fakeRenderer.destroy() + } catch (err) { + console.log('Cannot destroy correctly fake renderer.', err) + } + } + this.fakeRenderer = undefined + } + } + + private buildQualities () { + const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({ + id: file.resolution.id, + label: this.buildQualityLabel(file), + height: file.resolution.id, + selected: false, + selectCallback: () => this.changeQuality(file.resolution.id) + })) + + resolutions.push({ + id: -1, + label: this.player.localize('Auto'), + selected: true, + selectCallback: () => this.changeQuality(-1) + }) + + this.player.peertubeResolutions().add(resolutions) + } + + private buildQualityLabel (file: VideoFile) { + let label = file.resolution.label + + if (file.fps && file.fps >= 50) { + label += file.fps + } + + return label + } + + private selectAppropriateResolution (byEngine: boolean) { + const resolution = this.autoResolution + ? -1 + : this.getCurrentResolutionId() + + const autoResolutionChosen = this.autoResolution + ? this.getCurrentResolutionId() + : undefined + + this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine }) + } +} + +videojs.registerPlugin('webtorrent', WebTorrentPlugin) +export { WebTorrentPlugin } diff --git a/client/src/assets/player/stats/stats-card.ts b/client/src/assets/player/stats/stats-card.ts deleted file mode 100644 index e76a81a74..000000000 --- a/client/src/assets/player/stats/stats-card.ts +++ /dev/null @@ -1,271 +0,0 @@ -import videojs from 'video.js' -import { secondsToTime } from '@shared/core-utils' -import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../peertube-videojs-typings' -import { bytes } from '../utils' - -interface StatsCardOptions extends videojs.ComponentOptions { - videoUUID: string - videoIsLive: boolean - mode: 'webtorrent' | 'p2p-media-loader' - p2pEnabled: boolean -} - -interface PlayerNetworkInfo { - downloadSpeed?: string - uploadSpeed?: string - totalDownloaded?: string - totalUploaded?: string - numPeers?: number - averageBandwidth?: string - - downloadedFromServer?: string - downloadedFromPeers?: string -} - -const Component = videojs.getComponent('Component') -class StatsCard extends Component { - options_: StatsCardOptions - - container: HTMLDivElement - - list: HTMLDivElement - closeButton: HTMLElement - - updateInterval: any - - mode: 'webtorrent' | 'p2p-media-loader' - - metadataStore: any = {} - - intervalMs = 300 - playerNetworkInfo: PlayerNetworkInfo = {} - - createEl () { - const container = super.createEl('div', { - className: 'vjs-stats-content', - innerHTML: this.getMainTemplate() - }) as HTMLDivElement - this.container = container - this.container.style.display = 'none' - - this.closeButton = this.container.getElementsByClassName('vjs-stats-close')[0] as HTMLElement - this.closeButton.onclick = () => this.hide() - - this.list = this.container.getElementsByClassName('vjs-stats-list')[0] as HTMLDivElement - - this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { - if (!data) return // HTTP fallback - - this.mode = data.source - - const p2pStats = data.p2p - const httpStats = data.http - - this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ') - this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed).join(' ') - this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ') - this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ') - this.playerNetworkInfo.numPeers = p2pStats.numPeers - this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s' - - if (data.source === 'p2p-media-loader') { - this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') - this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') - } - }) - - return container - } - - toggle () { - if (this.updateInterval) this.hide() - else this.show() - } - - show () { - this.container.style.display = 'block' - this.updateInterval = setInterval(async () => { - try { - const options = this.mode === 'p2p-media-loader' - ? this.buildHLSOptions() - : await this.buildWebTorrentOptions() // Default - - this.list.innerHTML = this.getListTemplate(options) - } catch (err) { - console.error('Cannot update stats.', err) - clearInterval(this.updateInterval) - } - }, this.intervalMs) - } - - hide () { - clearInterval(this.updateInterval) - this.container.style.display = 'none' - } - - private buildHLSOptions () { - const p2pMediaLoader = this.player_.p2pMediaLoader() - const level = p2pMediaLoader.getCurrentLevel() - - const codecs = level?.videoCodec || level?.audioCodec - ? `${level?.videoCodec || ''} / ${level?.audioCodec || ''}` - : undefined - - const resolution = `${level?.height}p${level?.attrs['FRAME-RATE'] || ''}` - const buffer = this.timeRangesToString(this.player().buffered()) - - let progress: number - let latency: string - - if (this.options_.videoIsLive) { - latency = secondsToTime(p2pMediaLoader.getLiveLatency()) - } else { - progress = this.player().bufferedPercent() - } - - return { - playerNetworkInfo: this.playerNetworkInfo, - resolution, - codecs, - buffer, - latency, - progress - } - } - - private async buildWebTorrentOptions () { - const videoFile = this.player_.webtorrent().getCurrentVideoFile() - - if (!this.metadataStore[videoFile.fileUrl]) { - this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) - } - - const metadata = this.metadataStore[videoFile.fileUrl] - - let colorSpace = 'unknown' - let codecs = 'unknown' - - if (metadata?.streams[0]) { - const stream = metadata.streams[0] - - colorSpace = stream['color_space'] !== 'unknown' - ? stream['color_space'] - : 'bt709' - - codecs = stream['codec_name'] || 'avc1' - } - - const resolution = videoFile?.resolution.label + videoFile?.fps - const buffer = this.timeRangesToString(this.player().buffered()) - const progress = this.player_.webtorrent().getTorrent()?.progress - - return { - playerNetworkInfo: this.playerNetworkInfo, - progress, - colorSpace, - codecs, - resolution, - buffer - } - } - - private getListTemplate (options: { - playerNetworkInfo: PlayerNetworkInfo - progress: number - codecs: string - resolution: string - buffer: string - - latency?: string - colorSpace?: string - }) { - const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options - const player = this.player() - - const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality() - const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) - const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) - const pr = (window.devicePixelRatio || 1).toFixed(2) - const frames = `${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}` - - const duration = player.duration() - - let volume = `${Math.round(player.volume() * 100)}` - if (player.muted()) volume += ' (muted)' - - const networkActivity = playerNetworkInfo.downloadSpeed - ? `${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑` - : undefined - - const totalTransferred = playerNetworkInfo.totalDownloaded - ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑` - : undefined - const downloadBreakdown = playerNetworkInfo.downloadedFromServer - ? `${playerNetworkInfo.downloadedFromServer} from servers ยท ${playerNetworkInfo.downloadedFromPeers} from peers` - : undefined - - const bufferProgress = progress !== undefined - ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` - : undefined - - return ` - ${this.buildElement(player.localize('Player mode'), this.mode || 'HTTP')} - ${this.buildElement(player.localize('P2P'), player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled'))} - - ${this.buildElement(player.localize('Video UUID'), this.options_.videoUUID)} - - ${this.buildElement(player.localize('Viewport / Frames'), frames)} - - ${this.buildElement(player.localize('Resolution'), resolution)} - - ${this.buildElement(player.localize('Volume'), volume)} - - ${this.buildElement(player.localize('Codecs'), codecs)} - ${this.buildElement(player.localize('Color'), colorSpace)} - - ${this.buildElement(player.localize('Connection Speed'), playerNetworkInfo.averageBandwidth)} - - ${this.buildElement(player.localize('Network Activity'), networkActivity)} - ${this.buildElement(player.localize('Total Transfered'), totalTransferred)} - ${this.buildElement(player.localize('Download Breakdown'), downloadBreakdown)} - - ${this.buildElement(player.localize('Buffer Progress'), bufferProgress)} - ${this.buildElement(player.localize('Buffer State'), buffer)} - - ${this.buildElement(player.localize('Live Latency'), latency)} - ` - } - - private getMainTemplate () { - return ` - -
- ` - } - - private buildElement (label: string, value?: string) { - if (!value) return '' - - return `
${label}
${value}
` - } - - private timeRangesToString (r: videojs.TimeRange) { - let result = '' - - for (let i = 0; i < r.length; i++) { - const start = Math.floor(r.start(i)) - const end = Math.floor(r.end(i)) - - result += `[${secondsToTime(start)}, ${secondsToTime(end)}] ` - } - - return result - } -} - -videojs.registerComponent('StatsCard', StatsCard) - -export { - StatsCard, - StatsCardOptions -} diff --git a/client/src/assets/player/stats/stats-plugin.ts b/client/src/assets/player/stats/stats-plugin.ts deleted file mode 100644 index 8aad80e8a..000000000 --- a/client/src/assets/player/stats/stats-plugin.ts +++ /dev/null @@ -1,31 +0,0 @@ -import videojs from 'video.js' -import { StatsCard, StatsCardOptions } from './stats-card' - -const Plugin = videojs.getPlugin('plugin') - -class StatsForNerdsPlugin extends Plugin { - private statsCard: StatsCard - - constructor (player: videojs.Player, options: StatsCardOptions) { - const settings = { - ...options - } - - super(player) - - this.player.ready(() => { - player.addClass('vjs-stats-for-nerds') - }) - - this.statsCard = new StatsCard(player, options) - - player.addChild(this.statsCard, settings) - } - - show () { - this.statsCard.show() - } -} - -videojs.registerPlugin('stats', StatsForNerdsPlugin) -export { StatsForNerdsPlugin } diff --git a/client/src/assets/player/types/index.ts b/client/src/assets/player/types/index.ts new file mode 100644 index 000000000..b73e0b3cb --- /dev/null +++ b/client/src/assets/player/types/index.ts @@ -0,0 +1,2 @@ +export * from './manager-options' +export * from './peertube-videojs-typings' diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts new file mode 100644 index 000000000..b3ad7e337 --- /dev/null +++ b/client/src/assets/player/types/manager-options.ts @@ -0,0 +1,84 @@ +import { PluginsManager } from '@root-helpers/plugins-manager' +import { LiveVideoLatencyMode, VideoFile } from '@shared/models' +import { PlaylistPluginOptions, UserWatching, VideoJSCaption } from './peertube-videojs-typings' + +export type PlayerMode = 'webtorrent' | 'p2p-media-loader' + +export type WebtorrentOptions = { + videoFiles: VideoFile[] +} + +export type P2PMediaLoaderOptions = { + playlistUrl: string + segmentsSha256Url: string + trackerAnnounce: string[] + redundancyBaseUrls: string[] + videoFiles: VideoFile[] +} + +export interface CustomizationOptions { + startTime: number | string + stopTime: number | string + + controls?: boolean + muted?: boolean + loop?: boolean + subtitle?: string + resume?: string + + peertubeLink: boolean +} + +export interface CommonOptions extends CustomizationOptions { + playerElement: HTMLVideoElement + onPlayerElementChange: (element: HTMLVideoElement) => void + + autoplay: boolean + p2pEnabled: boolean + + nextVideo?: () => void + hasNextVideo?: () => boolean + + previousVideo?: () => void + hasPreviousVideo?: () => boolean + + playlist?: PlaylistPluginOptions + + videoDuration: number + enableHotkeys: boolean + inactivityTimeout: number + poster: string + + theaterButton: boolean + captions: boolean + + videoViewUrl: string + embedUrl: string + embedTitle: string + + isLive: boolean + liveOptions?: { + latencyMode: LiveVideoLatencyMode + } + + language?: string + + videoCaptions: VideoJSCaption[] + + videoUUID: string + videoShortUUID: string + + userWatching?: UserWatching + + serverUrl: string + + errorNotifier: (message: string) => void +} + +export type PeertubePlayerManagerOptions = { + common: CommonOptions + webtorrent: WebtorrentOptions + p2pMediaLoader?: P2PMediaLoaderOptions + + pluginsManager: PluginsManager +} diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts new file mode 100644 index 000000000..d9a388681 --- /dev/null +++ b/client/src/assets/player/types/peertube-videojs-typings.ts @@ -0,0 +1,242 @@ +import { HlsConfig, Level } from 'hls.js' +import videojs from 'video.js' +import { Engine } from '@peertube/p2p-media-loader-hlsjs' +import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' +import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' +import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' +import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' +import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' +import { PeerTubePlugin } from '../shared/peertube/peertube-plugin' +import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' +import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' +import { StatsCardOptions } from '../shared/stats/stats-card' +import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' +import { EndCardOptions } from '../shared/upnext/end-card' +import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' +import { PlayerMode } from './manager-options' + +declare module 'video.js' { + + export interface VideoJsPlayer { + srOptions_: HlsjsConfigHandlerOptions + + theaterEnabled: boolean + + // FIXME: add it to upstream typings + posterImage: { + show (): void + hide (): void + } + + handleTechSeeked_ (): void + + // Plugins + + peertube (): PeerTubePlugin + + webtorrent (): WebTorrentPlugin + + p2pMediaLoader (): P2pMediaLoaderPlugin + + peertubeResolutions (): PeerTubeResolutionsPlugin + + contextmenuUI (options: any): any + + bezels (): void + peertubeMobile (): void + peerTubeHotkeysPlugin (): void + + stats (options?: StatsCardOptions): StatsForNerdsPlugin + + textTracks (): TextTrackList & { + tracks_: (TextTrack & { id: string, label: string, src: string })[] + } + + peertubeDock (options: PeerTubeDockPluginOptions): void + + upnext (options: Partial): void + + playlist (): PlaylistPlugin + } +} + +export interface VideoJSTechHLS extends videojs.Tech { + hlsProvider: Html5Hlsjs +} + +export interface HlsjsConfigHandlerOptions { + hlsjsConfig?: HlsConfig + + levelLabelHandler?: (level: Level) => string +} + +type PeerTubeResolution = { + id: number + + height?: number + label?: string + width?: number + bitrate?: number + + selected: boolean + selectCallback: () => void +} + +type VideoJSCaption = { + label: string + language: string + src: string +} + +type UserWatching = { + url: string + authorizationHeader: string +} + +type PeerTubePluginOptions = { + mode: PlayerMode + + autoplay: boolean + videoViewUrl: string + videoDuration: number + + userWatching?: UserWatching + subtitle?: string + + videoCaptions: VideoJSCaption[] + + stopTime: number | string + + isLive: boolean + + videoUUID: string +} + +type PlaylistPluginOptions = { + elements: VideoPlaylistElement[] + + playlist: VideoPlaylist + + getCurrentPosition: () => number + + onItemClicked: (element: VideoPlaylistElement) => void +} + +type NextPreviousVideoButtonOptions = { + type: 'next' | 'previous' + handler: () => void + isDisabled: () => boolean +} + +type PeerTubeLinkButtonOptions = { + shortUUID: string +} + +type PeerTubeP2PInfoButtonOptions = { + p2pEnabled: boolean +} + +type WebtorrentPluginOptions = { + playerElement: HTMLVideoElement + + autoplay: boolean + videoDuration: number + + videoFiles: VideoFile[] + + startTime: number | string + + playerRefusedP2P: boolean +} + +type P2PMediaLoaderPluginOptions = { + redundancyUrlManager: RedundancyUrlManager + type: string + src: string + + startTime: number | string + + loader: P2PMediaLoader +} + +export type P2PMediaLoader = { + getEngine(): Engine +} + +type VideoJSPluginOptions = { + playlist?: PlaylistPluginOptions + + peertube: PeerTubePluginOptions + + webtorrent?: WebtorrentPluginOptions + + p2pMediaLoader?: P2PMediaLoaderPluginOptions +} + +type LoadedQualityData = { + qualitySwitchCallback: (resolutionId: number, type: 'video') => void + qualityData: { + video: { + id: number + label: string + selected: boolean + }[] + } +} + +type ResolutionUpdateData = { + auto: boolean + resolutionId: number + id?: number +} + +type AutoResolutionUpdateData = { + possible: boolean +} + +type PlayerNetworkInfo = { + source: 'webtorrent' | 'p2p-media-loader' + + http: { + downloadSpeed: number + uploadSpeed: number + downloaded: number + uploaded: number + } + + p2p: { + downloadSpeed: number + uploadSpeed: number + downloaded: number + uploaded: number + numPeers: number + } + + // In bytes + bandwidthEstimate: number +} + +type PlaylistItemOptions = { + element: VideoPlaylistElement + + onClicked: () => void +} + +export { + PlayerNetworkInfo, + PlaylistItemOptions, + NextPreviousVideoButtonOptions, + ResolutionUpdateData, + AutoResolutionUpdateData, + PlaylistPluginOptions, + VideoJSCaption, + UserWatching, + PeerTubePluginOptions, + WebtorrentPluginOptions, + P2PMediaLoaderPluginOptions, + PeerTubeResolution, + VideoJSPluginOptions, + LoadedQualityData, + PeerTubeLinkButtonOptions, + PeerTubeP2PInfoButtonOptions +} diff --git a/client/src/assets/player/upnext/end-card.ts b/client/src/assets/player/upnext/end-card.ts deleted file mode 100644 index 61668e407..000000000 --- a/client/src/assets/player/upnext/end-card.ts +++ /dev/null @@ -1,157 +0,0 @@ -import videojs from 'video.js' - -function getMainTemplate (options: any) { - return ` -
- ${options.headText} -
-
-
- - - - -
- - - - - ${options.suspendedText} - - ` -} - -export interface EndCardOptions extends videojs.ComponentOptions { - next: () => void - getTitle: () => string - timeout: number - cancelText: string - headText: string - suspendedText: string - condition: () => boolean - suspended: () => boolean -} - -const Component = videojs.getComponent('Component') -class EndCard extends Component { - options_: EndCardOptions - - dashOffsetTotal = 586 - dashOffsetStart = 293 - interval = 50 - upNextEvents = new videojs.EventTarget() - ticks = 0 - totalTicks: number - - container: HTMLDivElement - title: HTMLElement - autoplayRing: HTMLElement - cancelButton: HTMLElement - suspendedMessage: HTMLElement - nextButton: HTMLElement - - constructor (player: videojs.Player, options: EndCardOptions) { - super(player, options) - - this.totalTicks = this.options_.timeout / this.interval - - player.on('ended', (_: any) => { - if (!this.options_.condition()) return - - player.addClass('vjs-upnext--showing') - this.showCard((canceled: boolean) => { - player.removeClass('vjs-upnext--showing') - this.container.style.display = 'none' - if (!canceled) { - this.options_.next() - } - }) - }) - - player.on('playing', () => { - this.upNextEvents.trigger('playing') - }) - } - - createEl () { - const container = super.createEl('div', { - className: 'vjs-upnext-content', - innerHTML: getMainTemplate(this.options_) - }) as HTMLDivElement - - this.container = container - container.style.display = 'none' - - this.autoplayRing = container.getElementsByClassName('vjs-upnext-svg-autoplay-ring')[0] as HTMLElement - this.title = container.getElementsByClassName('vjs-upnext-title')[0] as HTMLElement - this.cancelButton = container.getElementsByClassName('vjs-upnext-cancel-button')[0] as HTMLElement - this.suspendedMessage = container.getElementsByClassName('vjs-upnext-suspended')[0] as HTMLElement - this.nextButton = container.getElementsByClassName('vjs-upnext-autoplay-icon')[0] as HTMLElement - - this.cancelButton.onclick = () => { - this.upNextEvents.trigger('cancel') - } - - this.nextButton.onclick = () => { - this.upNextEvents.trigger('next') - } - - return container - } - - showCard (cb: (value: boolean) => void) { - let timeout: any - - this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) - this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`) - - this.title.innerHTML = this.options_.getTitle() - - this.upNextEvents.one('cancel', () => { - clearTimeout(timeout) - cb(true) - }) - - this.upNextEvents.one('playing', () => { - clearTimeout(timeout) - cb(true) - }) - - this.upNextEvents.one('next', () => { - clearTimeout(timeout) - cb(false) - }) - - const goToPercent = (percent: number) => { - const newOffset = Math.max(-this.dashOffsetTotal, -this.dashOffsetStart - percent * this.dashOffsetTotal / 2 / 100) - this.autoplayRing.setAttribute('stroke-dashoffset', '' + newOffset) - } - - const tick = () => { - goToPercent((this.ticks++) * 100 / this.totalTicks) - } - - const update = () => { - if (this.options_.suspended()) { - this.suspendedMessage.innerText = this.options_.suspendedText - goToPercent(0) - this.ticks = 0 - timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer - } else if (this.ticks >= this.totalTicks) { - clearTimeout(timeout) - cb(false) - } else { - this.suspendedMessage.innerText = '' - tick() - timeout = setTimeout(update.bind(this), this.interval) - } - } - - this.container.style.display = 'block' - timeout = setTimeout(update.bind(this), this.interval) - } -} - -videojs.registerComponent('EndCard', EndCard) diff --git a/client/src/assets/player/upnext/upnext-plugin.ts b/client/src/assets/player/upnext/upnext-plugin.ts deleted file mode 100644 index db969024d..000000000 --- a/client/src/assets/player/upnext/upnext-plugin.ts +++ /dev/null @@ -1,31 +0,0 @@ -import videojs from 'video.js' -import { EndCardOptions } from './end-card' - -const Plugin = videojs.getPlugin('plugin') - -class UpNextPlugin extends Plugin { - - constructor (player: videojs.Player, options: Partial = {}) { - const settings = { - next: options.next, - getTitle: options.getTitle, - timeout: options.timeout || 5000, - cancelText: options.cancelText || 'Cancel', - headText: options.headText || 'Up Next', - suspendedText: options.suspendedText || 'Autoplay is suspended', - condition: options.condition, - suspended: options.suspended - } - - super(player) - - this.player.ready(() => { - player.addClass('vjs-upnext') - }) - - player.addChild('EndCard', settings) - } -} - -videojs.registerPlugin('upnext', UpNextPlugin) -export { UpNextPlugin } diff --git a/client/src/assets/player/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/webtorrent/peertube-chunk-store.ts deleted file mode 100644 index 81378c277..000000000 --- a/client/src/assets/player/webtorrent/peertube-chunk-store.ts +++ /dev/null @@ -1,233 +0,0 @@ -// From https://github.com/MinEduTDF/idb-chunk-store -// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues -// Thanks @santiagogil and @Feross - -import { EventEmitter } from 'events' -import Dexie from 'dexie' - -class ChunkDatabase extends Dexie { - chunks: Dexie.Table<{ id: number, buf: Buffer }, number> - - constructor (dbname: string) { - super(dbname) - - this.version(1).stores({ - chunks: 'id' - }) - } -} - -class ExpirationDatabase extends Dexie { - databases: Dexie.Table<{ name: string, expiration: number }, number> - - constructor () { - super('webtorrent-expiration') - - this.version(1).stores({ - databases: 'name,expiration' - }) - } -} - -export class PeertubeChunkStore extends EventEmitter { - private static readonly BUFFERING_PUT_MS = 1000 - private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute - private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes - - chunkLength: number - - private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = [] - // If the store is full - private memoryChunks: { [ id: number ]: Buffer | true } = {} - private databaseName: string - private putBulkTimeout: any - private cleanerInterval: any - private db: ChunkDatabase - private expirationDB: ExpirationDatabase - private readonly length: number - private readonly lastChunkLength: number - private readonly lastChunkIndex: number - - constructor (chunkLength: number, opts: any) { - super() - - this.databaseName = 'webtorrent-chunks-' - - if (!opts) opts = {} - if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash - else this.databaseName += '-default' - - this.setMaxListeners(100) - - this.chunkLength = Number(chunkLength) - if (!this.chunkLength) throw new Error('First argument must be a chunk length') - - this.length = Number(opts.length) || Infinity - - if (this.length !== Infinity) { - this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength - this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 - } - - this.db = new ChunkDatabase(this.databaseName) - // Track databases that expired - this.expirationDB = new ExpirationDatabase() - - this.runCleaner() - } - - put (index: number, buf: Buffer, cb: (err?: Error) => void) { - const isLastChunk = (index === this.lastChunkIndex) - if (isLastChunk && buf.length !== this.lastChunkLength) { - return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) - } - if (!isLastChunk && buf.length !== this.chunkLength) { - return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) - } - - // Specify we have this chunk - this.memoryChunks[index] = true - - // Add it to the pending put - this.pendingPut.push({ id: index, buf, cb }) - // If it's already planned, return - if (this.putBulkTimeout) return - - // Plan a future bulk insert - this.putBulkTimeout = setTimeout(async () => { - const processing = this.pendingPut - this.pendingPut = [] - this.putBulkTimeout = undefined - - try { - await this.db.transaction('rw', this.db.chunks, () => { - return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) - }) - } catch (err) { - console.log('Cannot bulk insert chunks. Store them in memory.', { err }) - - processing.forEach(p => { - this.memoryChunks[p.id] = p.buf - }) - } finally { - processing.forEach(p => p.cb()) - } - }, PeertubeChunkStore.BUFFERING_PUT_MS) - } - - get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { - if (typeof opts === 'function') return this.get(index, null, opts) - - // IndexDB could be slow, use our memory index first - const memoryChunk = this.memoryChunks[index] - if (memoryChunk === undefined) { - const err = new Error('Chunk not found') as any - err['notFound'] = true - - return process.nextTick(() => cb(err)) - } - - // Chunk in memory - if (memoryChunk !== true) return cb(null, memoryChunk) - - // Chunk in store - this.db.transaction('r', this.db.chunks, async () => { - const result = await this.db.chunks.get({ id: index }) - if (result === undefined) return cb(null, Buffer.alloc(0)) - - const buf = result.buf - if (!opts) return this.nextTick(cb, null, buf) - - const offset = opts.offset || 0 - const len = opts.length || (buf.length - offset) - return cb(null, buf.slice(offset, len + offset)) - }) - .catch(err => { - console.error(err) - return cb(err) - }) - } - - close (cb: (err?: Error) => void) { - return this.destroy(cb) - } - - async destroy (cb: (err?: Error) => void) { - try { - if (this.pendingPut) { - clearTimeout(this.putBulkTimeout) - this.pendingPut = null - } - if (this.cleanerInterval) { - clearInterval(this.cleanerInterval) - this.cleanerInterval = null - } - - if (this.db) { - this.db.close() - - await this.dropDatabase(this.databaseName) - } - - if (this.expirationDB) { - this.expirationDB.close() - this.expirationDB = null - } - - return cb() - } catch (err) { - console.error('Cannot destroy peertube chunk store.', err) - return cb(err) - } - } - - private runCleaner () { - this.checkExpiration() - - this.cleanerInterval = setInterval(() => { - this.checkExpiration() - }, PeertubeChunkStore.CLEANER_INTERVAL_MS) - } - - private async checkExpiration () { - let databasesToDeleteInfo: { name: string }[] = [] - - try { - await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { - // Update our database expiration since we are alive - await this.expirationDB.databases.put({ - name: this.databaseName, - expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS - }) - - const now = new Date().getTime() - databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() - }) - } catch (err) { - console.error('Cannot update expiration of fetch expired databases.', err) - } - - for (const databaseToDeleteInfo of databasesToDeleteInfo) { - await this.dropDatabase(databaseToDeleteInfo.name) - } - } - - private async dropDatabase (databaseName: string) { - const dbToDelete = new ChunkDatabase(databaseName) - console.log('Destroying IndexDB database %s.', databaseName) - - try { - await dbToDelete.delete() - - await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { - return this.expirationDB.databases.where({ name: databaseName }).delete() - }) - } catch (err) { - console.error('Cannot delete %s.', databaseName, err) - } - } - - private nextTick (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { - process.nextTick(() => cb(err, val), undefined) - } -} diff --git a/client/src/assets/player/webtorrent/video-renderer.ts b/client/src/assets/player/webtorrent/video-renderer.ts deleted file mode 100644 index 9b80fea2c..000000000 --- a/client/src/assets/player/webtorrent/video-renderer.ts +++ /dev/null @@ -1,133 +0,0 @@ -// Thanks: https://github.com/feross/render-media - -const MediaElementWrapper = require('mediasource') -import { extname } from 'path' -const Videostream = require('videostream') - -const VIDEOSTREAM_EXTS = [ - '.m4a', - '.m4v', - '.mp4' -] - -type RenderMediaOptions = { - controls: boolean - autoplay: boolean -} - -function renderVideo ( - file: any, - elem: HTMLVideoElement, - opts: RenderMediaOptions, - callback: (err: Error, renderer: any) => void -) { - validateFile(file) - - return renderMedia(file, elem, opts, callback) -} - -function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { - const extension = extname(file.name).toLowerCase() - let preparedElem: any - let currentTime = 0 - let renderer: any - - try { - if (VIDEOSTREAM_EXTS.includes(extension)) { - renderer = useVideostream() - } else { - renderer = useMediaSource() - } - } catch (err) { - return callback(err) - } - - function useVideostream () { - prepareElem() - preparedElem.addEventListener('error', function onError (err: Error) { - preparedElem.removeEventListener('error', onError) - - return callback(err) - }) - preparedElem.addEventListener('loadstart', onLoadStart) - return new Videostream(file, preparedElem) - } - - function useMediaSource (useVP9 = false) { - const codecs = getCodec(file.name, useVP9) - - prepareElem() - preparedElem.addEventListener('error', function onError (err: Error) { - preparedElem.removeEventListener('error', onError) - - // Try with vp9 before returning an error - if (codecs.includes('vp8')) return fallbackToMediaSource(true) - - return callback(err) - }) - preparedElem.addEventListener('loadstart', onLoadStart) - - const wrapper = new MediaElementWrapper(preparedElem) - const writable = wrapper.createWriteStream(codecs) - file.createReadStream().pipe(writable) - - if (currentTime) preparedElem.currentTime = currentTime - - return wrapper - } - - function fallbackToMediaSource (useVP9 = false) { - if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.') - else console.log('Falling back to media source..') - - useMediaSource(useVP9) - } - - function prepareElem () { - if (preparedElem === undefined) { - preparedElem = elem - - preparedElem.addEventListener('progress', function () { - currentTime = elem.currentTime - }) - } - } - - function onLoadStart () { - preparedElem.removeEventListener('loadstart', onLoadStart) - if (opts.autoplay) preparedElem.play() - - callback(null, renderer) - } -} - -function validateFile (file: any) { - if (file == null) { - throw new Error('file cannot be null or undefined') - } - if (typeof file.name !== 'string') { - throw new Error('missing or invalid file.name property') - } - if (typeof file.createReadStream !== 'function') { - throw new Error('missing or invalid file.createReadStream property') - } -} - -function getCodec (name: string, useVP9 = false) { - const ext = extname(name).toLowerCase() - if (ext === '.mp4') { - return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' - } - - if (ext === '.webm') { - if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' - - return 'video/webm; codecs="vp8, vorbis"' - } - - return undefined -} - -export { - renderVideo -} diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts deleted file mode 100644 index 4bcb2766a..000000000 --- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts +++ /dev/null @@ -1,640 +0,0 @@ -import videojs from 'video.js' -import * as WebTorrent from 'webtorrent' -import { timeToInt } from '@shared/core-utils' -import { VideoFile } from '@shared/models' -import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../peertube-player-local-storage' -import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../peertube-videojs-typings' -import { getRtcConfig, isIOS, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' -import { PeertubeChunkStore } from './peertube-chunk-store' -import { renderVideo } from './video-renderer' - -const CacheChunkStore = require('cache-chunk-store') - -type PlayOptions = { - forcePlay?: boolean - seek?: number - delay?: number -} - -const Plugin = videojs.getPlugin('plugin') - -class WebTorrentPlugin extends Plugin { - readonly videoFiles: VideoFile[] - - private readonly playerElement: HTMLVideoElement - - private readonly autoplay: boolean = false - private readonly startTime: number = 0 - private readonly savePlayerSrcFunction: videojs.Player['src'] - private readonly videoDuration: number - private readonly CONSTANTS = { - INFO_SCHEDULER: 1000, // Don't change this - AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds - AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it - AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check - AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds - BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth - } - - private readonly webtorrent = new WebTorrent({ - tracker: { - rtcConfig: getRtcConfig() - }, - dht: false - }) - - private currentVideoFile: VideoFile - private torrent: WebTorrent.Torrent - - private renderer: any - private fakeRenderer: any - private destroyingFakeRenderer = false - - private autoResolution = true - private autoResolutionPossible = true - private isAutoResolutionObservation = false - private playerRefusedP2P = false - - private torrentInfoInterval: any - private autoQualityInterval: any - private addTorrentDelay: any - private qualityObservationTimer: any - private runAutoQualitySchedulerTimer: any - - private downloadSpeeds: number[] = [] - - constructor (player: videojs.Player, options?: WebtorrentPluginOptions) { - super(player) - - this.startTime = timeToInt(options.startTime) - - // Custom autoplay handled by webtorrent because we lazy play the video - this.autoplay = options.autoplay - - this.playerRefusedP2P = options.playerRefusedP2P - - this.videoFiles = options.videoFiles - this.videoDuration = options.videoDuration - - this.savePlayerSrcFunction = this.player.src - this.playerElement = options.playerElement - - this.player.ready(() => { - const playerOptions = this.player.options_ - - const volume = getStoredVolume() - if (volume !== undefined) this.player.volume(volume) - - const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() - if (muted !== undefined) this.player.muted(muted) - - this.player.duration(options.videoDuration) - - this.initializePlayer() - this.runTorrentInfoScheduler() - - this.player.one('play', () => { - // Don't run immediately scheduler, wait some seconds the TCP connections are made - this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - }) - }) - } - - dispose () { - clearTimeout(this.addTorrentDelay) - clearTimeout(this.qualityObservationTimer) - clearTimeout(this.runAutoQualitySchedulerTimer) - - clearInterval(this.torrentInfoInterval) - clearInterval(this.autoQualityInterval) - - // Don't need to destroy renderer, video player will be destroyed - this.flushVideoFile(this.currentVideoFile, false) - - this.destroyFakeRenderer() - } - - getCurrentResolutionId () { - return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 - } - - updateVideoFile ( - videoFile?: VideoFile, - options: { - forcePlay?: boolean - seek?: number - delay?: number - } = {}, - done: () => void = () => { /* empty */ } - ) { - // Automatically choose the adapted video file - if (!videoFile) { - const savedAverageBandwidth = getAverageBandwidthInStore() - videoFile = savedAverageBandwidth - ? this.getAppropriateFile(savedAverageBandwidth) - : this.pickAverageVideoFile() - } - - if (!videoFile) { - throw Error(`Can't update video file since videoFile is undefined.`) - } - - // Don't add the same video file once again - if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { - return - } - - // Do not display error to user because we will have multiple fallback - this.player.peertube().hideFatalError(); - - // Hack to "simulate" src link in video.js >= 6 - // Without this, we can't play the video after pausing it - // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 - (this.player as any).src = () => true - const oldPlaybackRate = this.player.playbackRate() - - const previousVideoFile = this.currentVideoFile - this.currentVideoFile = videoFile - - // Don't try on iOS that does not support MediaSource - // Or don't use P2P if webtorrent is disabled - if (isIOS() || this.playerRefusedP2P) { - return this.fallbackToHttp(options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - } - - this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { - this.player.playbackRate(oldPlaybackRate) - return done() - }) - - this.selectAppropriateResolution(true) - } - - updateEngineResolution (resolutionId: number, delay = 0) { - // Remember player state - const currentTime = this.player.currentTime() - const isPaused = this.player.paused() - - // Hide bigPlayButton - if (!isPaused) { - this.player.bigPlayButton.hide() - } - - // Audio-only (resolutionId === 0) gets special treatment - if (resolutionId === 0) { - // Audio-only: show poster, do not auto-hide controls - this.player.addClass('vjs-playing-audio-only-content') - this.player.posterImage.show() - } else { - // Hide poster to have black background - this.player.removeClass('vjs-playing-audio-only-content') - this.player.posterImage.hide() - } - - const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) - const options = { - forcePlay: false, - delay, - seek: currentTime + (delay / 1000) - } - - this.updateVideoFile(newVideoFile, options) - } - - flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { - if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { - if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() - - this.webtorrent.remove(videoFile.magnetUri) - console.log('Removed ' + videoFile.magnetUri) - } - } - - disableAutoResolution () { - this.autoResolution = false - this.autoResolutionPossible = false - this.player.peertubeResolutions().disableAutoResolution() - } - - isAutoResolutionPossible () { - return this.autoResolutionPossible - } - - getTorrent () { - return this.torrent - } - - getCurrentVideoFile () { - return this.currentVideoFile - } - - changeQuality (id: number) { - if (id === -1) { - if (this.autoResolutionPossible === true) { - this.autoResolution = true - - this.selectAppropriateResolution(false) - } - - return - } - - this.autoResolution = false - this.updateEngineResolution(id) - this.selectAppropriateResolution(false) - } - - private addTorrent ( - magnetOrTorrentUrl: string, - previousVideoFile: VideoFile, - options: PlayOptions, - done: (err?: Error) => void - ) { - if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done) - - console.log('Adding ' + magnetOrTorrentUrl + '.') - - const oldTorrent = this.torrent - const torrentOptions = { - // Don't use arrow function: it breaks webtorrent (that uses `new` keyword) - store: function (chunkLength: number, storeOpts: any) { - return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { - max: 100 - }) - } - } - - this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { - console.log('Added ' + magnetOrTorrentUrl + '.') - - if (oldTorrent) { - // Pause the old torrent - this.stopTorrent(oldTorrent) - - // We use a fake renderer so we download correct pieces of the next file - if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay) - } - - // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) - this.addTorrentDelay = setTimeout(() => { - // We don't need the fake renderer anymore - this.destroyFakeRenderer() - - const paused = this.player.paused() - - this.flushVideoFile(previousVideoFile) - - // Update progress bar (just for the UI), do not wait rendering - if (options.seek) this.player.currentTime(options.seek) - - const renderVideoOptions = { autoplay: false, controls: true } - renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => { - this.renderer = renderer - - if (err) return this.fallbackToHttp(options, done) - - return this.tryToPlay(err => { - if (err) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - return done() - }) - }) - }, options.delay || 0) - }) - - this.torrent.on('error', (err: any) => console.error(err)) - - this.torrent.on('warning', (err: any) => { - // We don't support HTTP tracker but we don't care -> we use the web socket tracker - if (err.message.indexOf('Unsupported tracker protocol') !== -1) return - - // Users don't care about issues with WebRTC, but developers do so log it in the console - if (err.message.indexOf('Ice connection failed') !== -1) { - console.log(err) - return - } - - // Magnet hash is not up to date with the torrent file, add directly the torrent file - if (err.message.indexOf('incorrect info hash') !== -1) { - console.error('Incorrect info hash detected, falling back to torrent file.') - const newOptions = { forcePlay: true, seek: options.seek } - return this.addTorrent(this.torrent['xs'], previousVideoFile, newOptions, done) - } - - // Remote instance is down - if (err.message.indexOf('from xs param') !== -1) { - this.handleError(err) - } - - console.warn(err) - }) - } - - private tryToPlay (done?: (err?: Error) => void) { - if (!done) done = function () { /* empty */ } - - const playPromise = this.player.play() - if (playPromise !== undefined) { - return playPromise.then(() => done()) - .catch((err: Error) => { - if (err.message.includes('The play() request was interrupted by a call to pause()')) { - return - } - - console.error(err) - this.player.pause() - this.player.posterImage.show() - this.player.removeClass('vjs-has-autoplay') - this.player.removeClass('vjs-has-big-play-button-clicked') - this.player.removeClass('vjs-playing-audio-only-content') - - return done() - }) - } - - return done() - } - - private seek (time: number) { - this.player.currentTime(time) - this.player.handleTechSeeked_() - } - - private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { - if (this.videoFiles === undefined) return undefined - if (this.videoFiles.length === 1) return this.videoFiles[0] - - const files = this.videoFiles.filter(f => f.resolution.id !== 0) - if (files.length === 0) return undefined - - // Don't change the torrent if the player ended - if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile - - if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() - - // Limit resolution according to player height - const playerHeight = this.playerElement.offsetHeight - - // We take the first resolution just above the player height - // Example: player height is 530px, we want the 720p file instead of 480p - let maxResolution = files[0].resolution.id - for (let i = files.length - 1; i >= 0; i--) { - const resolutionId = files[i].resolution.id - if (resolutionId !== 0 && resolutionId >= playerHeight) { - maxResolution = resolutionId - break - } - } - - // Filter videos we can play according to our screen resolution and bandwidth - const filteredFiles = files.filter(f => f.resolution.id <= maxResolution) - .filter(f => { - const fileBitrate = (f.size / this.videoDuration) - let threshold = fileBitrate - - // If this is for a higher resolution or an initial load: add a margin - if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { - threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) - } - - return averageDownloadSpeed > threshold - }) - - // If the download speed is too bad, return the lowest resolution we have - if (filteredFiles.length === 0) return videoFileMinByResolution(files) - - return videoFileMaxByResolution(filteredFiles) - } - - private getAndSaveActualDownloadSpeed () { - const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) - const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) - if (lastDownloadSpeeds.length === 0) return -1 - - const sum = lastDownloadSpeeds.reduce((a, b) => a + b) - const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) - - // Save the average bandwidth for future use - saveAverageBandwidth(averageBandwidth) - - return averageBandwidth - } - - private initializePlayer () { - this.buildQualities() - - if (this.autoplay) { - this.player.posterImage.hide() - - return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - - // Proxy first play - const oldPlay = this.player.play.bind(this.player); - (this.player as any).play = () => { - this.player.addClass('vjs-has-big-play-button-clicked') - this.player.play = oldPlay - - this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) - } - } - - private runAutoQualityScheduler () { - this.autoQualityInterval = setInterval(() => { - - // Not initialized or in HTTP fallback - if (this.torrent === undefined || this.torrent === null) return - if (this.autoResolution === false) return - if (this.isAutoResolutionObservation === true) return - - const file = this.getAppropriateFile() - let changeResolution = false - let changeResolutionDelay = 0 - - // Lower resolution - if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { - console.log('Downgrading automatically the resolution to: %s', file.resolution.label) - changeResolution = true - } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution - console.log('Upgrading automatically the resolution to: %s', file.resolution.label) - changeResolution = true - changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY - } - - if (changeResolution === true) { - this.updateEngineResolution(file.resolution.id, changeResolutionDelay) - - // Wait some seconds in observation of our new resolution - this.isAutoResolutionObservation = true - - this.qualityObservationTimer = setTimeout(() => { - this.isAutoResolutionObservation = false - }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) - } - }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) - } - - private isPlayerWaiting () { - return this.player?.hasClass('vjs-waiting') - } - - private runTorrentInfoScheduler () { - this.torrentInfoInterval = setInterval(() => { - // Not initialized yet - if (this.torrent === undefined) return - - // Http fallback - if (this.torrent === null) return this.player.trigger('p2pInfo', false) - - // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too - if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) - - return this.player.trigger('p2pInfo', { - source: 'webtorrent', - http: { - downloadSpeed: 0, - uploadSpeed: 0, - downloaded: 0, - uploaded: 0 - }, - p2p: { - downloadSpeed: this.torrent.downloadSpeed, - numPeers: this.torrent.numPeers, - uploadSpeed: this.torrent.uploadSpeed, - downloaded: this.torrent.downloaded, - uploaded: this.torrent.uploaded - }, - bandwidthEstimate: this.webtorrent.downloadSpeed - } as PlayerNetworkInfo) - }, this.CONSTANTS.INFO_SCHEDULER) - } - - private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) { - const paused = this.player.paused() - - this.disableAutoResolution() - - this.flushVideoFile(this.currentVideoFile, true) - this.torrent = null - - // Enable error display now this is our last fallback - this.player.one('error', () => this.player.peertube().displayFatalError()) - - const httpUrl = this.currentVideoFile.fileUrl - this.player.src = this.savePlayerSrcFunction - this.player.src(httpUrl) - - this.selectAppropriateResolution(true) - - // We changed the source, so reinit captions - this.player.trigger('sourcechange') - - return this.tryToPlay(err => { - if (err && done) return done(err) - - if (options.seek) this.seek(options.seek) - if (options.forcePlay === false && paused === true) this.player.pause() - - if (done) return done() - }) - } - - private handleError (err: Error | string) { - return this.player.trigger('customError', { err }) - } - - private pickAverageVideoFile () { - if (this.videoFiles.length === 1) return this.videoFiles[0] - - const files = this.videoFiles.filter(f => f.resolution.id !== 0) - return files[Math.floor(files.length / 2)] - } - - private stopTorrent (torrent: WebTorrent.Torrent) { - torrent.pause() - // Pause does not remove actual peers (in particular the webseed peer) - torrent.removePeer(torrent['ws']) - } - - private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { - this.destroyingFakeRenderer = false - - const fakeVideoElem = document.createElement('video') - renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { - this.fakeRenderer = renderer - - // The renderer returns an error when we destroy it, so skip them - if (this.destroyingFakeRenderer === false && err) { - console.error('Cannot render new torrent in fake video element.', err) - } - - // Load the future file at the correct time (in delay MS - 2 seconds) - fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) - }) - } - - private destroyFakeRenderer () { - if (this.fakeRenderer) { - this.destroyingFakeRenderer = true - - if (this.fakeRenderer.destroy) { - try { - this.fakeRenderer.destroy() - } catch (err) { - console.log('Cannot destroy correctly fake renderer.', err) - } - } - this.fakeRenderer = undefined - } - } - - private buildQualities () { - const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({ - id: file.resolution.id, - label: this.buildQualityLabel(file), - height: file.resolution.id, - selected: false, - selectCallback: () => this.changeQuality(file.resolution.id) - })) - - resolutions.push({ - id: -1, - label: this.player.localize('Auto'), - selected: true, - selectCallback: () => this.changeQuality(-1) - }) - - this.player.peertubeResolutions().add(resolutions) - } - - private buildQualityLabel (file: VideoFile) { - let label = file.resolution.label - - if (file.fps && file.fps >= 50) { - label += file.fps - } - - return label - } - - private selectAppropriateResolution (byEngine: boolean) { - const resolution = this.autoResolution - ? -1 - : this.getCurrentResolutionId() - - const autoResolutionChosen = this.autoResolution - ? this.getCurrentResolutionId() - : undefined - - this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine }) - } -} - -videojs.registerPlugin('webtorrent', WebTorrentPlugin) -export { WebTorrentPlugin } -- cgit v1.2.3