diff --git a/src/App/App.js b/src/App/App.js index a284c9e27..f8c7e2b80 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -1,12 +1,12 @@ -import React, { StrictMode } from 'react'; -import { Router } from 'stremio-common'; -import routerConfig from './routerConfig'; -import styles from './styles'; +const React = require('react'); +const { Router } = require('stremio-common'); +const routerConfig = require('./routerConfig').default; +const styles = require('./styles'); const App = () => ( - + - + ); -export default App; +module.exports = App; diff --git a/src/App/index.js b/src/App/index.js index f1f2a246e..a553d5179 100644 --- a/src/App/index.js +++ b/src/App/index.js @@ -1,3 +1,3 @@ -import App from './App'; +const App = require('./App'); -export default App; +module.exports = App; diff --git a/src/index.html b/src/index.html index a6087bf9c..2f5091581 100755 --- a/src/index.html +++ b/src/index.html @@ -11,7 +11,7 @@
- + \ No newline at end of file diff --git a/src/index.js b/src/index.js index cfe50f77e..56143febe 100755 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,26 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; +const React = require('react'); +const ReactDOM = require('react-dom'); +const App = require('./App'); -window.runApp = () => { +const renderApp = () => { ReactDOM.render(, document.getElementById('app')); }; + +if (window.qt) { + window.shellOnLoad = () => { + window.shell.dispatch('mpv', 'setOption', null, 'terminal', 'yes'); + window.shell.dispatch('mpv', 'setOption', null, 'msg-level', 'all=v'); + window.shell.dispatch('mpv', 'setProp', null, 'vo', 'opengl-cb'); + window.shell.dispatch('mpv', 'setProp', null, 'opengl-hwdec-interop', 'auto'); + window.shell.dispatch('mpv', 'setProp', null, 'cache-default', 15000); + window.shell.dispatch('mpv', 'setProp', null, 'cache-backbuffer', 15000); + window.shell.dispatch('mpv', 'setProp', null, 'cache-secs', 10); + window.shell.dispatch('mpv', 'setProp', null, 'audio-client-name', 'Stremio'); + window.shell.dispatch('mpv', 'setProp', null, 'title', 'Stremio'); + window.shell.dispatch('mpv', 'setProp', null, 'audio-fallback-to-null', 'yes'); + window.shell.dispatch('mpv', 'setProp', null, 'sid', 'no'); + renderApp(); + }; +} else { + renderApp(); +} diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 495056d4a..caf562d47 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -40,7 +40,9 @@ class Player extends Component { } componentDidMount() { - this.dispatch('command', 'load', this.props.stream, {}); + this.dispatch('command', 'load', this.props.stream, { + ipc: window.shell + }); this.dispatch('setProp', 'subtitleOffset', 18); this.dispatch('command', 'addSubtitleTracks', [{ url: 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt', diff --git a/src/routes/Player/Video/Video.js b/src/routes/Player/Video/Video.js index 4f1f9306c..670306bd6 100644 --- a/src/routes/Player/Video/Video.js +++ b/src/routes/Player/Video/Video.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import hat from 'hat'; import HTMLVideo from './stremio-video/HTMLVideo'; import YouTubeVideo from './stremio-video/YouTubeVideo'; +import MPVVideo from './stremio-video/MPVVideo'; class Video extends Component { constructor(props) { @@ -22,7 +23,9 @@ class Video extends Component { } selectVideoImplementation = (stream, options) => { - if (stream.ytId) { + if (options.ipc) { + return MPVVideo; + } else if (stream.ytId) { return YouTubeVideo; } else { return HTMLVideo; @@ -34,7 +37,11 @@ class Video extends Component { const Video = this.selectVideoImplementation(args[2], args[3]); if (this.video === null || this.video.constructor !== Video) { this.dispatch('command', 'destroy'); - this.video = new Video({ containerElement: this.containerRef.current }); + this.video = new Video({ + ...args[3], + id: this.id, + containerElement: this.containerRef.current + }); this.video.on('ended', this.props.onEnded); this.video.on('error', this.props.onError); this.video.on('propValue', this.props.onPropValue); @@ -54,7 +61,7 @@ class Video extends Component { render() { return ( -
+
); } } diff --git a/src/routes/Player/Video/stremio-video/MPVVideo.js b/src/routes/Player/Video/stremio-video/MPVVideo.js new file mode 100644 index 000000000..27720136a --- /dev/null +++ b/src/routes/Player/Video/stremio-video/MPVVideo.js @@ -0,0 +1,436 @@ +var EventEmitter = require('events'); + +var MPV_CRITICAL_ERROR_CODES = []; + +function MPVVideo(options) { + var ipc = options && options.ipc; + var id = options && options.id; + if (!ipc) { + throw new Error('ipc parameter is required'); + } + + if (typeof id !== 'string') { + throw new Error('id parameter is required'); + } + + var ready = false; + var loaded = false; + var destroyed = false; + var events = new EventEmitter(); + var observedProps = {}; + var dispatchArgsReadyQueue = []; + + events.on('error', function() { }); + ipc.dispatch('mpv', 'createChannel', id) + .then(function() { + if (destroyed) { + return; + } + + ready = true; + Promise.all([getProp('volume'), getProp('mute')]).then(function(values) { + if (destroyed) { + return; + } + + onPropChanged('volume', values[0]); + onPropChanged('mute', values[1]); + }); + ipc.on('mpvEvent', onMpvEvent); + flushDispatchArgsQueue(dispatchArgsReadyQueue); + }) + .catch(function(error) { + onChannelError(error); + }); + + function mapPausedValue(pause) { + if (!loaded || typeof pause !== 'boolean') { + return null; + } + + return pause; + } + function mapTimeValue(timePos) { + if (!loaded || isNaN(timePos) || timePos === null) { + return null; + } + + return Math.round(timePos * 1000); + } + function mapDurationValue(duration) { + if (!loaded || isNaN(duration) || duration === null) { + return null; + } + + return Math.round(duration * 1000); + } + function mapBufferingValue(seeking, pausedForCache) { + if (!loaded || (seeking === null && pausedForCache === null)) { + return null; + } + + return !!seeking || !!pausedForCache; + } + function mapVolumeValue(volume) { + if (!ready || destroyed || isNaN(volume) || volume === null) { + return null; + } + + return volume; + } + function mapMutedValue(mute) { + if (!ready || destroyed || typeof mute !== 'boolean') { + return null; + } + + return mute; + } + function onError(error) { + if (destroyed || !error) { + return; + } + + Object.freeze(error); + events.emit('error', error); + if (error.critical) { + dispatch('command', 'stop'); + } + } + function onEnded() { + if (destroyed) { + return; + } + + events.emit('ended'); + } + function onPropChanged(propName, propValue) { + switch (propName) { + case 'pause': { + if (observedProps['paused']) { + events.emit('propChanged', 'paused', mapPausedValue(propValue)); + } + + break; + } + case 'time-pos': { + if (observedProps['time']) { + events.emit('propChanged', 'time', mapTimeValue(propValue)); + } + + break; + } + case 'duration': { + if (observedProps['duration']) { + events.emit('propChanged', 'duration', mapDurationValue(propValue)); + } + + break; + } + case 'seeking': { + if (observedProps['buffering']) { + events.emit('propChanged', 'buffering', mapBufferingValue(propValue, null)); + } + + break; + } + case 'paused-for-cache': { + if (observedProps['buffering']) { + events.emit('propChanged', 'buffering', mapBufferingValue(null, propValue)); + } + + break; + } + case 'volume': { + if (observedProps['volume']) { + events.emit('propChanged', 'volume', mapVolumeValue(propValue)); + } + + break; + } + case 'mute': { + if (observedProps['muted']) { + events.emit('propChanged', 'muted', mapMutedValue(propValue)); + } + + break; + } + } + } + function onMpvEvent(data) { + if (destroyed) { + return; + } + + if (!data || data.channelId !== id) { + onChannelError(ipc.errors.mpv_channel_id_expired); + return; + } + + switch (data.eventName) { + case 'error': { + onError(Object.assign({}, data, { + critical: MPV_CRITICAL_ERROR_CODES.indexOf(data.code) !== -1 + })); + break; + } + case 'ended': { + onEnded(); + break; + } + case 'propChanged': { + onPropChanged(data.propName, data.propValue); + break; + } + } + } + function onChannelError(error) { + if (destroyed) { + return; + } + + onError(Object.assign({}, error, { + critical: true + })); + dispatch('command', 'destroy'); + } + function observeProp(propName) { + ipc.dispatch('mpv', 'observeProp', id, propName) + .catch(function(error) { + onChannelError(error); + }); + } + function getProp(propName) { + return ipc.dispatch('mpv', 'getProp', id, propName) + .catch(function(error) { + onChannelError(error); + return null; + }); + } + function setProp(propName) { + ipc.dispatch('mpv', 'setProp', id, propName) + .catch(function(error) { + onChannelError(error); + }); + } + function command() { + ipc.dispatch.apply(null, ['mpv', 'command', id].concat(Array.from(arguments))) + .catch(function(error) { + onChannelError(error); + }); + } + function flushDispatchArgsQueue(dispatchArgsQueue) { + if (destroyed) { + return; + } + + while (dispatchArgsQueue.length > 0) { + var args = dispatchArgsQueue.shift(); + dispatch.apply(null, args); + } + } + function on(eventName, listener) { + if (destroyed) { + throw new Error('Video is destroyed'); + } + + events.on(eventName, listener); + } + function dispatch() { + if (destroyed) { + throw new Error('Video is destroyed'); + } + + switch (arguments[0]) { + case 'observeProp': { + switch (arguments[1]) { + case 'paused': { + observeProp('pause'); + getProp('pause').then(function(pause) { + if (destroyed) { + return; + } + + observedProps['paused'] = true; + events.emit('propValue', 'paused', mapPausedValue(pause)); + }); + return; + } + case 'time': { + observeProp('time-pos'); + getProp('time-pos').then(function(timePos) { + if (destroyed) { + return; + } + + observedProps['time'] = true; + events.emit('propValue', 'time', mapTimeValue(timePos)); + }); + return; + } + case 'duration': { + observeProp('duration'); + getProp('duration').then(function(duration) { + if (destroyed) { + return; + } + + observedProps['duration'] = true; + events.emit('propValue', 'duration', mapDurationValue(duration)); + }); + return; + } + case 'buffering': { + observeProp('seeking'); + observeProp('paused-for-cache'); + Promise.all([getProp('seeking'), getProp('paused-for-cache')]).then(function(values) { + if (destroyed) { + return; + } + + observedProps['buffering'] = true; + events.emit('propValue', 'buffering', mapBufferingValue(values[0], values[1])); + }); + return; + } + case 'volume': { + observeProp('volume'); + getProp('volume').then(function(volume) { + if (destroyed) { + return; + } + + observedProps['volume'] = true; + events.emit('propValue', 'volume', mapVolumeValue(volume)); + }); + return; + } + case 'muted': { + observeProp('mute'); + getProp('mute').then(function(mute) { + if (destroyed) { + return; + } + + observedProps['muted'] = true; + events.emit('propValue', 'muted', mapMutedValue(mute)); + }); + return; + } + } + } + case 'setProp': { + switch (arguments[1]) { + case 'paused': { + if (loaded) { + setProp('pause', !!arguments[2]); + } + + return; + } + case 'time': { + if (loaded && !isNaN(arguments[2]) && arguments[2] !== null) { + setProp('time-pos', arguments[2] / 1000); + } + + return; + } + case 'volume': { + if (ready) { + if (!isNaN(arguments[2]) && arguments[2] !== null) { + setProp('mute', false); + setProp('volume', Math.max(0, Math.min(100, arguments[2]))); + } + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + + return; + } + case 'muted': { + if (ready) { + setProp('mute', !!arguments[2]); + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + + return; + } + } + } + case 'command': { + switch (arguments[1]) { + case 'stop': { + loaded = false; + if (ready) { + command('stop'); + } + onPropChanged('pause', null); + onPropChanged('time-pos', null); + onPropChanged('duration', null); + onPropChanged('seeking', null); + onPropChanged('paused-for-cache', null); + return; + } + case 'load': { + if (ready) { + dispatch('command', 'stop'); + loaded = true; + var startTime = !isNaN(arguments[3].time) && arguments[3].time !== null ? Math.round(arguments[3].time / 1000) : 0; + command('loadfile', arguments[2].url, 'replace', 'time-pos=' + startTime); + setProp('pause', arguments[3].autoplay === false); + Promise.all([ + getProp('pause'), + getProp('time-pos'), + getProp('duration'), + getProp('seeking'), + getProp('paused-for-cache') + ]).then(function(values) { + if (destroyed) { + return; + } + + onPropChanged('pause', values[0]); + onPropChanged('time-pos', values[1]); + onPropChanged('duration', values[2]); + onPropChanged('seeking', values[3]); + onPropChanged('paused-for-cache', values[4]); + }); + } else { + dispatchArgsReadyQueue.push(Array.from(arguments)); + } + + return; + } + case 'destroy': { + dispatch('command', 'stop'); + destroyed = true; + onPropChanged('volume', null); + onPropChanged('mute', null); + events.removeAllListeners(); + events.on('error', function() { }); + ipc.off('mpvEvent', onMpvEvent); + dispatchArgsReadyQueue = []; + return; + } + } + } + } + + throw new Error('Invalid dispatch call: ' + Array.from(arguments).map(String)); + } + + this.on = on; + this.dispatch = dispatch; + + Object.freeze(this); +} + +MPVVideo.manifest = Object.freeze({ + name: 'MPVVideo', + embedded: true, + props: Object.freeze(['paused', 'time', 'duration', 'volume', 'muted', 'buffering']) +}); + +Object.freeze(MPVVideo); + +module.exports = MPVVideo;