From 80cc9697f7b499dd730561eb1999c5816ebd1f3f Mon Sep 17 00:00:00 2001 From: NikolaBorislavovHristov Date: Mon, 18 Feb 2019 17:37:42 +0200 Subject: [PATCH] YouTube video view refactored --- .../Video/stremio-video/YouTubeVideo.js | 658 +++++++++++++----- 1 file changed, 484 insertions(+), 174 deletions(-) diff --git a/src/routes/Player/Video/stremio-video/YouTubeVideo.js b/src/routes/Player/Video/stremio-video/YouTubeVideo.js index 35e66e775..0035cb386 100644 --- a/src/routes/Player/Video/stremio-video/YouTubeVideo.js +++ b/src/routes/Player/Video/stremio-video/YouTubeVideo.js @@ -1,97 +1,45 @@ var EventEmitter = require('events'); +var HTMLSubtitles = require('./HTMLSubtitles'); -var YouTubeVideo = function(containerElement) { - var videoElement = document.createElement('div'); - containerElement.appendChild(videoElement); - var events = new EventEmitter(); +function YouTubeVideo(containerElement) { + if (!(containerElement instanceof HTMLElement)) { + throw new Error('Instance of HTMLElement required as a first argument'); + } + + var self = this; var ready = false; - var observedProps = {}; - var timeIntervalId = null; - var durationIntervalId = null; - var dispatchArgsQueue = []; - var onEnded = function() { - events.emit('ended'); - }; - var onError = function(event) { - var message; - var critical; - switch (event.data) { - case 2: - message = 'Invalid request'; - critical = true; - break; - case 5: - message = 'The requested content cannot be played'; - critical = true; - break; - case 100: - message = 'The video has been removed or marked as private'; - critical = true; - break; - case 101: - case 150: - message = 'The video cannot be played in embedded players'; - critical = true; - break; - default: - message = 'Unknown error'; - critical = true; - } + var loaded = false; + var destroyed = false; + var events = new EventEmitter(); + var dispatchArgsReadyQueue = []; + var dispatchArgsLoadedQueue = []; + var pausedObserved = false; + var timeObserved = false; + var durationObserved = false; + var bufferingObserved = false; + var volumeObserved = false; + var timeChangedIntervalId = window.setInterval(onTimeChangedInterval, 100); + var durationChangedIntervalId = window.setInterval(onDurationChangedInterval, 100); + var volumeChangedIntervalId = window.setInterval(onVolumeChangedInterval, 100); + var subtitles = new HTMLSubtitles(containerElement); + var video = null; + // TODO handle script element + var stylesElement = document.createElement('style'); + var videoContainer = document.createElement('div'); - events.emit('error', { - code: event.data, - message: message, - critical: critical - }); - }; - var onPausedChanged = function() { - events.emit('propChanged', 'paused', video.getPlayerState() !== YT.PlayerState.PLAYING); - }; - var onTimeChanged = function() { - events.emit('propChanged', 'time', video.getCurrentTime() * 1000); - }; - var onDurationChanged = function() { - events.emit('propChanged', 'duration', video.getDuration() !== 0 ? video.getDuration() * 1000 : null); - }; - var onVolumeChanged = function(volume) { - events.emit('propChanged', 'volume', volume); - }; - var onReady = function() { - ready = true; - dispatchArgsQueue.forEach(function(args) { - this.dispatch.apply(this, args); - }, this); - dispatchArgsQueue = []; - }; - var onStateChange = function(event) { - switch (event.data) { - case YT.PlayerState.ENDED: - onEnded(); - break; - case YT.PlayerState.PLAYING: - if (observedProps.paused) { - onPausedChanged(); - } + subtitles.addListener('error', onSubtitlesError); + subtitles.addListener('load', updateSubtitleText); + containerElement.appendChild(stylesElement); + stylesElement.sheet.insertRule('#' + containerElement.id + ' .video { position: absolute; width: 100%; height: 100%; z-index: -1; }', stylesElement.sheet.cssRules.length); + containerElement.appendChild(videoContainer); + videoContainer.classList.add('video'); - if (observedProps.duration) { - onDurationChanged(); - } - break; - case YT.PlayerState.PAUSED: - if (observedProps.paused) { - onPausedChanged(); - } - break; - case YT.PlayerState.UNSTARTED: - if (observedProps.paused) { - onPausedChanged(); - } - break; - } - }; - var video; YT.ready(() => { - video = new YT.Player(videoElement, { + if (destroyed) { + return; + } + + video = new YT.Player(videoContainer, { height: '100%', width: '100%', playerVars: { @@ -108,106 +56,468 @@ var YouTubeVideo = function(containerElement) { rel: 0 }, events: { - onError: onError.bind(this), - onReady: onReady.bind(this), - onStateChange: onStateChange.bind(this) + onError: onVideoError, + onReady: onVideoReady, + onStateChange: onVideoStateChange } }); }); - this.on = function(eventName, listener) { - events.on(eventName, listener); + function getPaused() { + if (!loaded) { + return null; + } + + return video.getPlayerState() !== YT.PlayerState.PLAYING; + } + function getTime() { + if (!loaded) { + return null; + } + + return Math.floor(video.getCurrentTime() * 1000); + } + function getDuration() { + if (!loaded) { + return null; + } + + return Math.floor(video.getDuration() * 1000); + } + function getBuffering() { + if (!loaded) { + return null; + } + + return video.getPlayerState() === YT.PlayerState.BUFFERING; + } + function getVolume() { + if (!ready || destroyed) { + return null; + } + + return video.isMuted() ? 0 : video.getVolume(); + } + function getSubtitleTracks() { + if (!loaded) { + return Object.freeze([]); + } + + return subtitles.dispatch('getProp', 'tracks'); + } + function getSelectedSubtitleTrackId() { + if (!loaded) { + return null; + } + + return subtitles.dispatch('getProp', 'selectedTrackId'); + } + function getSubtitleDelay() { + if (!loaded) { + return null; + } + + return subtitles.dispatch('getProp', 'delay'); + } + function getSubtitleSize() { + if (!ready || destroyed) { + return null; + } + + return subtitles.dispatch('getProp', 'size'); + } + function getSubtitleDarkBackground() { + if (!ready || destroyed) { + return null; + } + + return subtitles.dispatch('getProp', 'darkBackground'); + } + function onEnded() { + events.emit('ended'); + } + function onError(error) { + Object.freeze(error); + events.emit('error', error); + if (error.critical) { + self.dispatch('command', 'stop'); + } + } + function onPausedChanged() { + events.emit('propChanged', 'paused', getPaused()); + } + function onTimeChanged() { + events.emit('propChanged', 'time', getTime()); + } + function onDurationChanged() { + events.emit('propChanged', 'duration', getDuration()); + } + function onBufferingChanged() { + events.emit('propChanged', 'buffering', getBuffering()); + } + function onVolumeChanged() { + events.emit('propChanged', 'volume', getVolume()); + } + function onSubtitleTracksChanged() { + events.emit('propChanged', 'subtitleTracks', getSubtitleTracks()); + } + function onSelectedSubtitleTrackIdChanged() { + events.emit('propChanged', 'selectedSubtitleTrackId', getSelectedSubtitleTrackId()); + } + function onSubtitleDelayChanged() { + events.emit('propChanged', 'subtitleDelay', getSubtitleDelay()); + } + function onSubtitleSizeChanged() { + events.emit('propChanged', 'subtitleSize', getSubtitleSize()); + } + function onSubtitleDarkBackgroundChanged() { + events.emit('propChanged', 'subtitleDarkBackground', getSubtitleDarkBackground()); + } + function onSubtitlesError(error) { + var message; + switch (error.code) { + case 70: + message = 'Failed to fetch subtitles from ' + error.track.origin; + break; + case 71: + message = 'Failed to parse subtitles from ' + error.track.origin; + break; + default: + message = 'Unknown subtitles error'; + } + + onError({ + code: error.code, + message: message, + critical: false + }); + } + function onVideoError(error) { + var message; + switch (error.data) { + case 2: + message = 'Invalid request'; + break; + case 5: + message = 'The requested content cannot be played'; + break; + case 100: + message = 'The video has been removed or marked as private'; + break; + case 101: + case 150: + message = 'The video cannot be played in embedded players'; + break; + default: + message = 'Unknown error'; + } + + onError({ + code: error.data, + message: message, + critical: true + }); + } + function onVideoReady() { + ready = true; + onVolumeChanged(); + onSubtitleSizeChanged(); + onSubtitleDarkBackgroundChanged(); + flushDispatchArgsQueue(dispatchArgsReadyQueue); + } + function onVideoStateChange(state) { + if (bufferingObserved) { + onBufferingChanged(); + } + + switch (state.data) { + case YT.PlayerState.ENDED: + onEnded(); + break; + case YT.PlayerState.PAUSED: + case YT.PlayerState.PLAYING: + if (pausedObserved) { + onPausedChanged(); + } + + if (timeObserved) { + onTimeChanged(); + } + + if (durationObserved) { + onDurationChanged(); + } + + break; + case YT.PlayerState.UNSTARTED: + if (pausedObserved) { + onPausedChanged(); + } + + break; + } + } + function onTimeChangedInterval() { + updateSubtitleText(); + if (timeObserved) { + onTimeChanged(); + } + } + function onDurationChangedInterval() { + if (durationObserved) { + onDurationChanged(); + } + } + function onVolumeChangedInterval() { + if (volumeObserved) { + onVolumeChanged(); + } + } + function updateSubtitleText() { + subtitles.dispatch('command', 'updateText', getTime()); + } + function flushDispatchArgsQueue(dispatchArgsQueue) { + while (dispatchArgsQueue.length > 0) { + var args = dispatchArgsQueue.shift(); + self.dispatch.apply(self, args); + } + } + + this.addListener = function(eventName, listener) { + if (destroyed) { + throw new Error('Unable to add ' + eventName + ' listener'); + } + + events.addListener(eventName, listener); + }; + + this.removeListener = function(eventName, listener) { + if (destroyed) { + throw new Error('Unable to remove ' + eventName + ' listener'); + } + + events.removeListener(eventName, listener); }; this.dispatch = function() { - if (arguments[0] === 'observeProp') { - switch (arguments[1]) { - case 'paused': - if (ready) { - events.emit('propValue', 'paused', video.getPlayerState() !== YT.PlayerState.PLAYING); - observedProps.paused = true; - } - break; - case 'time': - if (ready) { - events.emit('propValue', 'time', video.getCurrentTime() * 1000); - if (timeIntervalId === null) { - timeIntervalId = window.setInterval(onTimeChanged, 100); - } - } - break; - case 'duration': - if (ready) { - events.emit('propValue', 'duration', video.getDuration() !== 0 ? video.getDuration() * 1000 : null); - observedProps.duration = true; - if (durationIntervalId === null) { - durationIntervalId = window.setInterval(onDurationChanged, 5000); - } - } - break; - case 'volume': - if (ready) { - events.emit('propValue', 'volume', video.getVolume()); - } - break; - default: - throw new Error('observeProp not supported: ' + arguments[1]); - } - } else if (arguments[0] === 'setProp') { - switch (arguments[1]) { - case 'paused': - if (ready) { - arguments[2] ? video.pauseVideo() : video.playVideo(); - } - break; - case 'time': - if (ready) { - video.seekTo(arguments[2] / 1000); - } - break; - case 'volume': - if (ready) { - video.setVolume(arguments[2]); - onVolumeChanged(arguments[2]); - } - break; - default: - throw new Error('setProp not supported: ' + arguments[1]); - } - } else if (arguments[0] === 'command') { - switch (arguments[1]) { - case 'load': - if (ready) { - video.loadVideoById({ - videoId: arguments[2].ytId, - startSeconds: isNaN(arguments[3].time) ? 0 : arguments[3].time / 1000 - }); - } - break; - case 'stop': - if (ready) { - events.removeAllListeners(); - observedProps = {}; - clearInterval(timeIntervalId); - clearInterval(durationIntervalId); - video.pauseVideo(); - } - break; - default: - throw new Error('command not supported: ' + arguments[1]); - } + console.log(Array.from(arguments).map(String)) + if (destroyed) { + throw new Error('Unable to dispatch ' + arguments[0]); } - if (!ready) { - dispatchArgsQueue.push(Array.from(arguments)); + switch (arguments[0]) { + case 'observeProp': + switch (arguments[1]) { + case 'paused': + events.emit('propValue', 'paused', getPaused()); + pausedObserved = true; + return; + case 'time': + events.emit('propValue', 'time', getTime()); + timeObserved = true; + return; + case 'duration': + events.emit('propValue', 'duration', getDuration()); + durationObserved = true; + return; + case 'buffering': + events.emit('propValue', 'duration', getBuffering()); + bufferingObserved = true; + return; + case 'volume': + events.emit('propValue', 'volume', getVolume()); + volumeObserved = true; + return; + case 'subtitleTracks': + events.emit('propValue', 'subtitleTracks', getSubtitleTracks()); + return; + case 'selectedSubtitleTrackId': + events.emit('propValue', 'selectedSubtitleTrackId', getSelectedSubtitleTrackId()); + return; + case 'subtitleSize': + events.emit('propValue', 'subtitleSize', getSubtitleSize()); + return; + case 'subtitleDelay': + events.emit('propValue', 'subtitleDelay', getSubtitleDelay()); + return; + case 'subtitleDarkBackground': + events.emit('propValue', 'subtitleDarkBackground', getSubtitleDarkBackground()); + return; + default: + throw new Error('observeProp not supported: ' + arguments[1]); + } + case 'setProp': + switch (arguments[1]) { + case 'paused': + if (loaded) { + arguments[2] ? video.pauseVideo() : video.playVideo(); + } else { + dispatchArgsLoadedQueue.push(Array.from(arguments)); + } + return; + case 'time': + if (loaded) { + if (!isNaN(arguments[2])) { + video.seekTo(arguments[2] / 1000); + } + } else { + dispatchArgsLoadedQueue.push(Array.from(arguments)); + } + return; + case 'volume': + if (ready) { + if (!isNaN(arguments[2])) { + video.unMute(); + video.setVolume(Math.max(0, Math.min(100, arguments[2]))); + } + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + return; + case 'selectedSubtitleTrackId': + if (loaded) { + subtitles.dispatch('setProp', 'selectedTrackId', arguments[2]); + onSubtitleDelayChanged(); + onSelectedSubtitleTrackIdChanged(); + updateSubtitleText(); + } else { + dispatchArgsLoadedQueue.push(Array.from(arguments)); + } + return; + case 'subtitleSize': + if (ready) { + subtitles.dispatch('setProp', 'size', arguments[2]); + onSubtitleSizeChanged(); + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + return; + case 'subtitleDelay': + if (loaded) { + subtitles.dispatch('setProp', 'delay', arguments[2]); + onSubtitleDelayChanged(); + updateSubtitleText(); + } else { + dispatchArgsLoadedQueue.push(Array.from(arguments)); + } + return; + case 'subtitleDarkBackground': + if (ready) { + subtitles.dispatch('setProp', 'darkBackground', arguments[2]); + onSubtitleDarkBackgroundChanged(); + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + return; + default: + throw new Error('setProp not supported: ' + arguments[1]); + } + case 'command': + switch (arguments[1]) { + case 'addSubtitleTracks': + if (loaded) { + subtitles.dispatch('command', 'addTracks', arguments[2]); + onSubtitleTracksChanged(); + } else { + dispatchArgsLoadedQueue.push(Array.from(arguments)); + } + return; + case 'mute': + if (ready) { + video.mute(); + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + return; + case 'unmute': + if (ready) { + video.unMute(); + if (video.getVolume() === 0) { + video.setVolume(50); + } + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + return; + case 'stop': + loaded = false; + dispatchArgsLoadedQueue = []; + subtitles.dispatch('command', 'clearTracks'); + if (ready) { + video.stopVideo(); + } + onPausedChanged(); + onTimeChanged(); + onDurationChanged(); + onBufferingChanged(); + onSubtitleTracksChanged(); + onSelectedSubtitleTrackIdChanged(); + onSubtitleDelayChanged(); + updateSubtitleText(); + return; + case 'load': + if (ready) { + debugger; + var dispatchArgsLoadedQueueCopy = dispatchArgsLoadedQueue.slice(); + self.dispatch('command', 'stop'); + dispatchArgsLoadedQueue = dispatchArgsLoadedQueueCopy; + var autoplay = typeof arguments[3].autoplay === 'boolean' ? arguments[3].autoplay : true; + var time = !isNaN(arguments[3].time) ? arguments[3].time / 1000 : 0; + if (autoplay) { + video.loadVideoById({ + videoId: arguments[2].ytId, + startSeconds: time + }); + } else { + video.cueVideoById({ + videoId: arguments[2].ytId, + startSeconds: time + }); + } + loaded = true; + onPausedChanged(); + onTimeChanged(); + onDurationChanged(); + onBufferingChanged(); + onSubtitleDelayChanged(); + updateSubtitleText(); + flushDispatchArgsQueue(dispatchArgsLoadedQueue); + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + return; + case 'destroy': + self.dispatch('command', 'stop'); + destroyed = true; + onVolumeChanged(); + onSubtitleSizeChanged(); + onSubtitleDarkBackgroundChanged(); + events.removeAllListeners(); + clearInterval(timeChangedIntervalId); + clearInterval(durationChangedIntervalId); + clearInterval(volumeChangedIntervalId); + video.destroy(); + containerElement.removeChild(videoElement); + containerElement.removeChild(stylesElement); + subtitles.dispatch('command', 'destroy'); + return; + default: + throw new Error('command not supported: ' + arguments[1]); + } + default: + throw new Error('Invalid dispatch call: ' + Array.from(arguments).map(String)); } }; + + Object.freeze(this); }; -YouTubeVideo.manifest = { +YouTubeVideo.manifest = Object.freeze({ name: 'YouTubeVideo', embedded: true, - props: ['paused', 'time', 'duration', 'volume'] -}; + props: Object.freeze(['paused', 'time', 'duration', 'volume', 'buffering', 'subtitleTracks', 'selectedSubtitleTrackId', 'subtitleSize', 'subtitleDelay', 'subtitleDarkBackground']) +}); + +Object.freeze(YouTubeVideo); module.exports = YouTubeVideo;