From aa8b6df4a51c82eb91e6fd71a090b2128098af6b Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 6 Oct 2017 10:40:09 +0200 Subject: Client: handle multiple file resolutions --- .../src/assets/player/peertube-videojs-plugin.ts | 238 +++++++++++++++++++++ client/src/assets/player/video-renderer.ts | 119 +++++++++++ 2 files changed, 357 insertions(+) create mode 100644 client/src/assets/player/peertube-videojs-plugin.ts create mode 100644 client/src/assets/player/video-renderer.ts (limited to 'client/src/assets/player') diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts new file mode 100644 index 000000000..090cc53ba --- /dev/null +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -0,0 +1,238 @@ +// Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher + +import videojs, { Player } from 'video.js' +import * as WebTorrent from 'webtorrent' + +import { renderVideo } from './video-renderer' +import { VideoFile } from '../../../../shared' + +// videojs typings don't have some method we need +const videojsUntyped = videojs as any +const webtorrent = new WebTorrent({ dht: false }) + +const MenuItem = videojsUntyped.getComponent('MenuItem') +const ResolutionMenuItem = videojsUntyped.extend(MenuItem, { + constructor: function (player: Player, options) { + options.selectable = true + MenuItem.call(this, player, options) + + const currentResolution = this.player_.getCurrentResolution() + this.selected(this.options_.id === currentResolution) + }, + + handleClick: function (event) { + MenuItem.prototype.handleClick.call(this, event) + this.player_.updateResolution(this.options_.id) + } +}) +MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) + +const MenuButton = videojsUntyped.getComponent('MenuButton') +const ResolutionMenuButton = videojsUntyped.extend(MenuButton, { + constructor: function (player, options) { + this.label = document.createElement('span') + options.label = 'Quality' + + MenuButton.call(this, player, options) + this.el().setAttribute('aria-label', 'Quality') + this.controlText('Quality') + + videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label') + this.el().appendChild(this.label) + + player.on('videoFileUpdate', videojs.bind(this, this.update)) + }, + + createItems: function () { + const menuItems = [] + for (const videoFile of this.player_.videoFiles) { + menuItems.push(new ResolutionMenuItem( + this.player_, + { + id: videoFile.resolution, + label: videoFile.resolutionLabel, + src: videoFile.magnetUri, + selected: videoFile.resolution === this.currentSelection + }) + ) + } + + return menuItems + }, + + update: function () { + this.label.innerHTML = this.player_.getCurrentResolutionLabel() + return MenuButton.prototype.update.call(this) + }, + + buildCSSClass: function () { + return MenuButton.prototype.buildCSSClass.call(this) + ' vjs-resolution-button' + } +}) +MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) + +const Button = videojsUntyped.getComponent('Button') +const PeertubeLinkButton = videojsUntyped.extend(Button, { + constructor: function (player) { + Button.apply(this, arguments) + this.player = player + }, + + createEl: function () { + const link = document.createElement('a') + link.href = window.location.href.replace('embed', 'watch') + link.innerHTML = 'PeerTube' + link.title = 'Go to the video page' + link.className = 'vjs-peertube-link' + link.target = '_blank' + + return link + }, + + handleClick: function () { + this.player.pause() + } +}) +Button.registerComponent('PeerTubeLinkButton', PeertubeLinkButton) + +type PeertubePluginOptions = { + videoFiles: VideoFile[] + playerElement: HTMLVideoElement + autoplay: boolean + peerTubeLink: boolean +} +const peertubePlugin = function (options: PeertubePluginOptions) { + const player = this + let currentVideoFile: VideoFile = undefined + const playerElement = options.playerElement + player.videoFiles = options.videoFiles + + // 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 + player.src = function () { + return true + } + + player.getCurrentResolution = function () { + return currentVideoFile ? currentVideoFile.resolution : -1 + } + + player.getCurrentResolutionLabel = function () { + return currentVideoFile ? currentVideoFile.resolutionLabel : '' + } + + player.updateVideoFile = function (videoFile: VideoFile, done: () => void) { + if (done === undefined) { + done = () => { /* empty */ } + } + + // Pick the first one + if (videoFile === undefined) { + videoFile = player.videoFiles[0] + } + + // Don't add the same video file once again + if (currentVideoFile !== undefined && currentVideoFile.magnetUri === videoFile.magnetUri) { + return + } + + const previousVideoFile = currentVideoFile + currentVideoFile = videoFile + + console.log('Adding ' + videoFile.magnetUri + '.') + player.torrent = webtorrent.add(videoFile.magnetUri, torrent => { + console.log('Added ' + videoFile.magnetUri + '.') + + this.flushVideoFile(previousVideoFile) + + const options = { autoplay: true, controls: true } + renderVideo(torrent.files[0], playerElement, options,(err, renderer) => { + if (err) return handleError(err) + + this.renderer = renderer + player.play() + + return done() + }) + }) + + player.torrent.on('error', err => handleError(err)) + player.torrent.on('warning', err => handleError(err)) + + player.trigger('videoFileUpdate') + + return player + } + + player.updateResolution = function (resolution) { + // Remember player state + const currentTime = player.currentTime() + const isPaused = player.paused() + + // Hide bigPlayButton + if (!isPaused && this.player_.options_.bigPlayButton) { + this.player_.bigPlayButton.hide() + } + + const newVideoFile = player.videoFiles.find(f => f.resolution === resolution) + player.updateVideoFile(newVideoFile, () => { + player.currentTime(currentTime) + player.handleTechSeeked_() + }) + } + + player.flushVideoFile = function (videoFile: VideoFile, destroyRenderer = true) { + if (videoFile !== undefined && webtorrent.get(videoFile.magnetUri)) { + if (destroyRenderer === true) this.renderer.destroy() + webtorrent.remove(videoFile.magnetUri) + } + } + + player.ready(function () { + const controlBar = player.controlBar + + const menuButton = new ResolutionMenuButton(player, options) + const fullscreenElement = controlBar.fullscreenToggle.el() + controlBar.resolutionSwitcher = controlBar.el().insertBefore(menuButton.el(), fullscreenElement) + controlBar.resolutionSwitcher.dispose = function () { + this.parentNode.removeChild(this) + } + + player.dispose = function () { + // Don't need to destroy renderer, video player will be destroyed + player.flushVideoFile(currentVideoFile, false) + } + + if (options.peerTubeLink === true) { + const peerTubeLinkButton = new PeertubeLinkButton(player) + controlBar.peerTubeLink = controlBar.el().insertBefore(peerTubeLinkButton.el(), fullscreenElement) + + controlBar.peerTubeLink.dispose = function () { + this.parentNode.removeChild(this) + } + } + + if (options.autoplay === true) { + player.updateVideoFile() + } else { + player.one('play', () => player.updateVideoFile()) + } + + setInterval(() => { + if (player.torrent !== undefined) { + player.trigger('torrentInfo', { + downloadSpeed: player.torrent.downloadSpeed, + numPeers: player.torrent.numPeers, + uploadSpeed: player.torrent.uploadSpeed + }) + } + }, 1000) + }) + + function handleError (err: Error|string) { + return player.trigger('customError', { err }) + } +} + +videojsUntyped.registerPlugin('peertube', peertubePlugin) diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/video-renderer.ts new file mode 100644 index 000000000..8baa42533 --- /dev/null +++ b/client/src/assets/player/video-renderer.ts @@ -0,0 +1,119 @@ +// Thanks: https://github.com/feross/render-media +// TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed + +import { extname } from 'path' +import * as MediaElementWrapper from 'mediasource' +import * as videostream from 'videostream' + +const VIDEOSTREAM_EXTS = [ + '.m4a', + '.m4v', + '.mp4' +] + +type RenderMediaOptions = { + controls: boolean + autoplay: boolean +} + +function renderVideo ( + file, + elem: HTMLVideoElement, + opts: RenderMediaOptions, + callback: (err: Error, renderer: any) => void +) { + validateFile(file) + + return renderMedia(file, elem, opts, callback) +} + +function renderMedia (file, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer: any) => void) { + const extension = extname(file.name).toLowerCase() + let preparedElem = undefined + let currentTime = 0 + let renderer + + if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) { + renderer = useVideostream() + } else { + renderer = useMediaSource() + } + + function useVideostream () { + prepareElem() + preparedElem.addEventListener('error', fallbackToMediaSource) + preparedElem.addEventListener('loadstart', onLoadStart) + preparedElem.addEventListener('canplay', onCanPlay) + return videostream(file, preparedElem) + } + + function useMediaSource () { + prepareElem() + preparedElem.addEventListener('error', callback) + preparedElem.addEventListener('loadstart', onLoadStart) + preparedElem.addEventListener('canplay', onCanPlay) + + const wrapper = new MediaElementWrapper(preparedElem) + const writable = wrapper.createWriteStream(getCodec(file.name)) + file.createReadStream().pipe(writable) + + if (currentTime) preparedElem.currentTime = currentTime + + return wrapper + } + + function fallbackToMediaSource () { + preparedElem.removeEventListener('error', fallbackToMediaSource) + preparedElem.removeEventListener('canplay', onCanPlay) + + useMediaSource() + } + + 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() + } + + function onCanPlay () { + preparedElem.removeEventListener('canplay', onCanPlay) + callback(null, renderer) + } +} + +function validateFile (file) { + 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) { + const ext = extname(name).toLowerCase() + return { + '.m4a': 'audio/mp4; codecs="mp4a.40.5"', + '.m4v': 'video/mp4; codecs="avc1.640029, mp4a.40.5"', + '.mkv': 'video/webm; codecs="avc1.640029, mp4a.40.5"', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4; codecs="avc1.640029, mp4a.40.5"', + '.webm': 'video/webm; codecs="vorbis, vp8"' + }[ext] +} + +export { + renderVideo +} -- cgit v1.2.3