├── .npmignore ├── .gitignore ├── README.md ├── src ├── HTMLVideo │ ├── index.js │ ├── getContentType.js │ ├── hlsConfig.js │ └── HTMLVideo.js ├── index.js ├── ShellVideo │ ├── index.js │ └── ShellVideo.js ├── TitanVideo │ ├── index.js │ └── TitanVideo.js ├── TizenVideo │ └── index.js ├── VidaaVideo │ ├── index.js │ └── VidaaVideo.js ├── WebOsVideo │ └── index.js ├── IFrameVideo │ ├── index.js │ └── IFrameVideo.js ├── StremioVideo │ ├── index.js │ ├── selectVideoImplementation.js │ └── StremioVideo.js ├── YouTubeVideo │ ├── index.js │ └── YouTubeVideo.js ├── withVideoParams │ ├── index.js │ └── withVideoParams.js ├── withHTMLSubtitles │ ├── index.js │ ├── subtitlesRenderer.js │ ├── binarySearchUpperBound.js │ ├── subtitlesConverter.js │ ├── subtitlesParser.js │ └── withHTMLSubtitles.js ├── withStreamingServer │ ├── index.js │ ├── isPlayerLoaded.js │ ├── createTorrent.js │ ├── convertStream.js │ ├── fetchVideoParams.js │ └── withStreamingServer.js ├── ChromecastSenderVideo │ ├── index.js │ └── ChromecastSenderVideo.js ├── platform.js ├── supportsTranscoding.js ├── tracksData.js ├── error.js └── mediaCapabilities.js ├── .github └── workflows │ ├── lint.yml │ └── publish.yml ├── package.json └── .eslintrc /.npmignore: -------------------------------------------------------------------------------- 1 | /.github 2 | .eslintrc 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /package-lock.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stremio-video 2 | 3 | Abstraction layer on top of different media players 4 | -------------------------------------------------------------------------------- /src/HTMLVideo/index.js: -------------------------------------------------------------------------------- 1 | var HTMLVideo = require('./HTMLVideo'); 2 | 3 | module.exports = HTMLVideo; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var StremioVideo = require('./StremioVideo'); 2 | 3 | module.exports = StremioVideo; 4 | -------------------------------------------------------------------------------- /src/ShellVideo/index.js: -------------------------------------------------------------------------------- 1 | var ShellVideo = require('./ShellVideo'); 2 | 3 | module.exports = ShellVideo; 4 | -------------------------------------------------------------------------------- /src/TitanVideo/index.js: -------------------------------------------------------------------------------- 1 | var TitanVideo = require('./TitanVideo'); 2 | 3 | module.exports = TitanVideo; 4 | -------------------------------------------------------------------------------- /src/TizenVideo/index.js: -------------------------------------------------------------------------------- 1 | var TizenVideo = require('./TizenVideo'); 2 | 3 | module.exports = TizenVideo; 4 | -------------------------------------------------------------------------------- /src/VidaaVideo/index.js: -------------------------------------------------------------------------------- 1 | var VidaaVideo = require('./VidaaVideo'); 2 | 3 | module.exports = VidaaVideo; 4 | -------------------------------------------------------------------------------- /src/WebOsVideo/index.js: -------------------------------------------------------------------------------- 1 | var WebOsVideo = require('./WebOsVideo'); 2 | 3 | module.exports = WebOsVideo; 4 | -------------------------------------------------------------------------------- /src/IFrameVideo/index.js: -------------------------------------------------------------------------------- 1 | var IFrameVideo = require('./IFrameVideo'); 2 | 3 | module.exports = IFrameVideo; 4 | -------------------------------------------------------------------------------- /src/StremioVideo/index.js: -------------------------------------------------------------------------------- 1 | var StremioVideo = require('./StremioVideo'); 2 | 3 | module.exports = StremioVideo; 4 | -------------------------------------------------------------------------------- /src/YouTubeVideo/index.js: -------------------------------------------------------------------------------- 1 | var YouTubeVideo = require('./YouTubeVideo'); 2 | 3 | module.exports = YouTubeVideo; 4 | -------------------------------------------------------------------------------- /src/withVideoParams/index.js: -------------------------------------------------------------------------------- 1 | var withVideoParams = require('./withVideoParams'); 2 | 3 | module.exports = withVideoParams; 4 | -------------------------------------------------------------------------------- /src/withHTMLSubtitles/index.js: -------------------------------------------------------------------------------- 1 | var withHTMLSubtitles = require('./withHTMLSubtitles'); 2 | 3 | module.exports = withHTMLSubtitles; 4 | -------------------------------------------------------------------------------- /src/withStreamingServer/index.js: -------------------------------------------------------------------------------- 1 | var withStreamingServer = require('./withStreamingServer'); 2 | 3 | module.exports = withStreamingServer; 4 | -------------------------------------------------------------------------------- /src/ChromecastSenderVideo/index.js: -------------------------------------------------------------------------------- 1 | var ChromecastSenderVideo = require('./ChromecastSenderVideo'); 2 | 3 | module.exports = ChromecastSenderVideo; 4 | -------------------------------------------------------------------------------- /src/platform.js: -------------------------------------------------------------------------------- 1 | var platform = null; 2 | 3 | module.exports = { 4 | set: function(val) { platform = val; }, 5 | get: function() { return platform; } 6 | }; 7 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | - name: Install NPM Dependencies 12 | run: npm install 13 | - name: Lint 14 | run: npm run lint 15 | -------------------------------------------------------------------------------- /src/supportsTranscoding.js: -------------------------------------------------------------------------------- 1 | var platform = require('./platform'); 2 | 3 | function supportsTranscoding() { 4 | if (['Tizen', 'webOS', 'Titan', 'NetTV'].includes(platform.get()) || typeof window.qt !== 'undefined') { 5 | return Promise.resolve(false); 6 | } 7 | return Promise.resolve(true); 8 | } 9 | 10 | module.exports = supportsTranscoding; 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Setup NodeJS 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 16 15 | registry-url: https://registry.npmjs.org/ 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | - name: Install NPM Dependencies 19 | run: npm install 20 | - name: Publish to NPM 21 | run: npm publish --access public 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 24 | -------------------------------------------------------------------------------- /src/tracksData.js: -------------------------------------------------------------------------------- 1 | module.exports = function(url, cb) { 2 | fetch('http://127.0.0.1:11470/tracks/'+encodeURIComponent(url)).then(function(resp) { 3 | return resp.json(); 4 | }).then(function(tracks) { 5 | var audioTracks = tracks.filter(function(el) { return (el || {}).type === 'audio'; }); 6 | var subsTracks = tracks.filter(function(el) { return (el || {}).type === 'text'; }); 7 | cb({ audio: audioTracks, subs: subsTracks }); 8 | }).catch(function(err) { 9 | // eslint-disable-next-line no-console 10 | console.error(err); 11 | cb(false); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/withHTMLSubtitles/subtitlesRenderer.js: -------------------------------------------------------------------------------- 1 | var VTTJS = require('vtt.js'); 2 | var binarySearchUpperBound = require('./binarySearchUpperBound'); 3 | 4 | function render(cuesByTime, time) { 5 | var nodes = []; 6 | var timeIndex = binarySearchUpperBound(cuesByTime.times, time); 7 | if (timeIndex !== -1) { 8 | var cuesForTime = cuesByTime[cuesByTime.times[timeIndex]]; 9 | for (var i = 0; i < cuesForTime.length; i++) { 10 | var node = VTTJS.WebVTT.convertCueToDOMTree(window, cuesForTime[i].text); 11 | nodes.push(node); 12 | } 13 | } 14 | 15 | return nodes; 16 | } 17 | 18 | module.exports = { 19 | render: render 20 | }; 21 | -------------------------------------------------------------------------------- /src/withHTMLSubtitles/binarySearchUpperBound.js: -------------------------------------------------------------------------------- 1 | function binarySearchUpperBound(array, value) { 2 | if (value < array[0] || array[array.length - 1] < value) { 3 | return -1; 4 | } 5 | 6 | var left = 0; 7 | var right = array.length - 1; 8 | var index = -1; 9 | while (left <= right) { 10 | var middle = Math.floor((left + right) / 2); 11 | if (array[middle] > value) { 12 | right = middle - 1; 13 | } else if (array[middle] < value) { 14 | left = middle + 1; 15 | } else { 16 | index = middle; 17 | left = middle + 1; 18 | } 19 | } 20 | 21 | return index !== -1 ? index : right; 22 | } 23 | 24 | module.exports = binarySearchUpperBound; 25 | -------------------------------------------------------------------------------- /src/HTMLVideo/getContentType.js: -------------------------------------------------------------------------------- 1 | function getContentType(stream) { 2 | if (!stream || typeof stream.url !== 'string') { 3 | return Promise.reject(new Error('Invalid stream parameter!')); 4 | } 5 | 6 | if (stream.behaviorHints && stream.behaviorHints.proxyHeaders && stream.behaviorHints.proxyHeaders.response && typeof stream.behaviorHints.proxyHeaders.response['content-type'] === 'string') { 7 | return Promise.resolve(stream.behaviorHints.proxyHeaders.response['content-type']); 8 | } 9 | 10 | return fetch(stream.url, { method: 'HEAD' }) 11 | .then(function(resp) { 12 | if (resp.ok) { 13 | return resp.headers.get('content-type'); 14 | } 15 | 16 | throw new Error(resp.status + ' (' + resp.statusText + ')'); 17 | }); 18 | } 19 | 20 | module.exports = getContentType; 21 | -------------------------------------------------------------------------------- /src/withStreamingServer/isPlayerLoaded.js: -------------------------------------------------------------------------------- 1 | function isPlayerLoaded(video, props) { 2 | if (!props.includes('loaded')) { 3 | return Promise.resolve(true); 4 | } 5 | return new Promise(function(resolve, reject) { 6 | var isLoaded = null; 7 | video.on('propChanged', function(propName, propValue) { 8 | if (propName === 'loaded' && propValue !== null && isLoaded === null) { 9 | isLoaded = propValue; 10 | if (propValue === true) { 11 | resolve(true); 12 | } else if (propValue === false) { 13 | reject(Error('Player failed to load, will not retrieve video params')); 14 | } 15 | } 16 | }); 17 | video.dispatch({ 18 | type: 'observeProp', 19 | propName: 'loaded' 20 | }); 21 | }); 22 | } 23 | 24 | module.exports = isPlayerLoaded; 25 | -------------------------------------------------------------------------------- /src/HTMLVideo/hlsConfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | debug: false, 3 | enableWorker: true, 4 | lowLatencyMode: false, 5 | backBufferLength: 30, 6 | maxBufferLength: 50, 7 | maxMaxBufferLength: 80, 8 | maxFragLookUpTolerance: 0, 9 | maxBufferHole: 0, 10 | appendErrorMaxRetry: 20, 11 | nudgeMaxRetry: 20, 12 | manifestLoadingTimeOut: 30000, 13 | manifestLoadingMaxRetry: 10, 14 | fragLoadPolicy: { 15 | default: { 16 | maxTimeToFirstByteMs: 10000, 17 | maxLoadTimeMs: 120000, 18 | timeoutRetry: { 19 | maxNumRetry: 20, 20 | retryDelayMs: 0, 21 | maxRetryDelayMs: 15 22 | }, 23 | errorRetry: { 24 | maxNumRetry: 6, 25 | retryDelayMs: 1000, 26 | maxRetryDelayMs: 15 27 | } 28 | } 29 | } 30 | // liveDurationInfinity: false 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stremio/stremio-video", 3 | "version": "0.0.69", 4 | "description": "Abstraction layer on top of different media players", 5 | "author": "Smart Code OOD", 6 | "main": "src/index.js", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/stremio/stremio-video.git" 11 | }, 12 | "scripts": { 13 | "lint": "eslint src" 14 | }, 15 | "dependencies": { 16 | "buffer": "6.0.3", 17 | "color": "4.2.3", 18 | "deep-freeze": "0.0.1", 19 | "eventemitter3": "4.0.7", 20 | "hat": "0.0.3", 21 | "hls.js": "https://github.com/Stremio/hls.js/releases/download/v1.5.4-patch2/hls.js-1.5.4-patch2.tgz", 22 | "lodash.clonedeep": "4.5.0", 23 | "magnet-uri": "6.2.0", 24 | "url": "0.11.0", 25 | "video-name-parser": "1.4.6", 26 | "vtt.js": "github:jaruba/vtt.js#84d33d157848407d790d78423dacc41a096294f0" 27 | }, 28 | "devDependencies": { 29 | "eslint": "7.32.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/error.js: -------------------------------------------------------------------------------- 1 | var ERROR = { 2 | CHROMECAST_SENDER_VIDEO: { 3 | INVALID_MESSAGE_RECEIVED: { 4 | code: 100, 5 | message: 'Invalid message received' 6 | }, 7 | MESSAGE_SEND_FAILED: { 8 | code: 101, 9 | message: 'Failed to send message' 10 | } 11 | }, 12 | YOUTUBE_VIDEO: { 13 | API_LOAD_FAILED: { 14 | code: 90, 15 | message: 'YouTube player iframe API failed to load', 16 | }, 17 | INVALID_PARAMETER: { 18 | code: 91, 19 | message: 'The request contains an invalid parameter value' 20 | }, 21 | HTML5_VIDEO: { 22 | code: 92, 23 | message: 'The requested content cannot be played in an HTML5 player' 24 | }, 25 | VIDEO_NOT_FOUND: { 26 | code: 93, 27 | message: 'The video requested was not found' 28 | }, 29 | VIDEO_NOT_EMBEDDABLE: { 30 | code: 94, 31 | message: 'The owner of the requested video does not allow it to be played in embedded players' 32 | } 33 | }, 34 | HTML_VIDEO: { 35 | MEDIA_ERR_ABORTED: { 36 | code: 80, 37 | message: 'Fetching process aborted' 38 | }, 39 | MEDIA_ERR_NETWORK: { 40 | code: 81, 41 | message: 'Error occurred when downloading' 42 | }, 43 | MEDIA_ERR_DECODE: { 44 | code: 82, 45 | message: 'Error occurred when decoding' 46 | }, 47 | MEDIA_ERR_SRC_NOT_SUPPORTED: { 48 | code: 83, 49 | message: 'Video is not supported' 50 | } 51 | }, 52 | WITH_HTML_SUBTITLES: { 53 | LOAD_FAILED: { 54 | code: 70, 55 | message: 'Failed to load external subtitles' 56 | } 57 | }, 58 | WITH_STREAMING_SERVER: { 59 | CONVERT_FAILED: { 60 | code: 60, 61 | message: 'Your device does not support the stream' 62 | } 63 | }, 64 | UNKNOWN_ERROR: { 65 | code: 1, 66 | message: 'Unknown error' 67 | }, 68 | UNSUPPORTED_STREAM: { 69 | code: 2, 70 | message: 'Stream is not supported' 71 | }, 72 | STREAM_FAILED_TO_LOAD: { 73 | code: 3, 74 | message: 'Stream failed to load' 75 | } 76 | }; 77 | 78 | module.exports = ERROR; 79 | -------------------------------------------------------------------------------- /src/withHTMLSubtitles/subtitlesConverter.js: -------------------------------------------------------------------------------- 1 | // from: https://github.com/silviapfeiffer/silviapfeiffer.github.io/blob/master/index.html#L150-L216 2 | 3 | function srt2webvtt(data) { 4 | // remove dos newlines 5 | var srt = data.replace(/\r+/g, ''); 6 | // trim white space start and end 7 | srt = srt.replace(/^\s+|\s+$/g, ''); 8 | // get cues 9 | var cuelist = srt.split('\n\n'); 10 | var result = ''; 11 | if (cuelist.length > 0) { 12 | result += 'WEBVTT\n\n'; 13 | for (var i = 0; i < cuelist.length; i = i + 1) { 14 | result += convertSrtCue(cuelist[i]); 15 | } 16 | } 17 | return result; 18 | } 19 | 20 | function convertSrtCue(caption) { 21 | // remove all html tags for security reasons 22 | caption = caption.replace(/<[a-zA-Z/][^>]*>/g, ''); 23 | 24 | var cue = ''; 25 | var s = caption.split(/\n/); 26 | // concatenate muilt-line string separated in array into one 27 | while (s.length > 3) { 28 | for (var i = 3; i < s.length; i++) { 29 | s[2] += '\n' + s[i]; 30 | } 31 | s.splice(3, s.length - 3); 32 | } 33 | var line = 0; 34 | // detect identifier 35 | if (!s[0].match(/\d+:\d+:\d+/) && s[1].match(/\d+:\d+:\d+/)) { 36 | cue += s[0].match(/\w+/) + '\n'; 37 | line += 1; 38 | } 39 | // get time strings 40 | if (s[line].match(/\d+:\d+:\d+/)) { 41 | // convert time string 42 | var m = s[1].match(/(\d+):(\d+):(\d+)(?:,(\d+))?\s*--?>\s*(\d+):(\d+):(\d+)(?:,(\d+))?/); 43 | if (m) { 44 | cue += m[1] + ':' + m[2] + ':' + m[3] + '.' + m[4] + ' --> ' 45 | + m[5] + ':' + m[6] + ':' + m[7] + '.' + m[8] + '\n'; 46 | line += 1; 47 | } else { 48 | // Unrecognized timestring 49 | return ''; 50 | } 51 | } else { 52 | // file format error or comment lines 53 | return ''; 54 | } 55 | // get cue text 56 | if (s[line]) { 57 | cue += s[line] + '\n\n'; 58 | } 59 | return cue; 60 | } 61 | 62 | module.exports = { 63 | convert: function(text) { 64 | // presume all to be SRT if not WEBVTT 65 | if (text.includes('WEBVTT')) { 66 | return text; 67 | } 68 | 69 | try { 70 | return srt2webvtt(text); 71 | } catch (error) { 72 | throw new Error('Failed to convert srt to webvtt: ' + error.message); 73 | } 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended" 4 | ], 5 | "globals": { 6 | "YT": "readonly", 7 | "Promise": "readonly", 8 | "cast": "readonly" 9 | }, 10 | "env": { 11 | "commonjs": true, 12 | "browser": true, 13 | "es6": true 14 | }, 15 | "parserOptions": { 16 | "ecmaVersion": 8 17 | }, 18 | "ignorePatterns": [ 19 | "/*", 20 | "!/src" 21 | ], 22 | "rules": { 23 | "arrow-parens": "error", 24 | "arrow-spacing": "error", 25 | "block-spacing": "error", 26 | "comma-spacing": "error", 27 | "eol-last": "error", 28 | "eqeqeq": "error", 29 | "func-call-spacing": "error", 30 | "indent": [ 31 | "error", 32 | 4, 33 | { 34 | "SwitchCase": 1 35 | } 36 | ], 37 | "no-console": [ 38 | "error", 39 | { 40 | "allow": [ 41 | "warn" 42 | ] 43 | } 44 | ], 45 | "no-extra-semi": "error", 46 | "no-eq-null": "error", 47 | "no-multi-spaces": "error", 48 | "no-multiple-empty-lines": [ 49 | "error", 50 | { 51 | "max": 1 52 | } 53 | ], 54 | "no-empty": [ 55 | "error", 56 | { 57 | "allowEmptyCatch": true 58 | } 59 | ], 60 | "no-inner-declarations": "off", 61 | "no-prototype-builtins": "off", 62 | "no-template-curly-in-string": "error", 63 | "no-trailing-spaces": "error", 64 | "no-useless-concat": "error", 65 | "no-unreachable": "error", 66 | "no-unused-vars": [ 67 | "error", 68 | { 69 | "varsIgnorePattern": "_" 70 | } 71 | ], 72 | "quotes": [ 73 | "error", 74 | "single" 75 | ], 76 | "quote-props": [ 77 | "error", 78 | "as-needed", 79 | { 80 | "unnecessary": false 81 | } 82 | ], 83 | "semi": "error", 84 | "semi-spacing": "error", 85 | "curly": [ 86 | "error", 87 | "multi-line" 88 | ], 89 | "space-before-blocks": "error", 90 | "valid-typeof": [ 91 | "error", 92 | { 93 | "requireStringLiterals": true 94 | } 95 | ], 96 | "no-redeclare": "off" 97 | } 98 | } -------------------------------------------------------------------------------- /src/withStreamingServer/createTorrent.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | function buildTorrent(streamingServerURL, infoHash, fileIdx, sources) { 4 | var query = Array.isArray(sources) && sources.length > 0 ? 5 | '?' + new URLSearchParams(sources.map(function(source) { 6 | return ['tr', source]; 7 | })) 8 | : 9 | ''; 10 | return { 11 | url: url.resolve(streamingServerURL, '/' + encodeURIComponent(infoHash) + '/' + encodeURIComponent(fileIdx)) + query, 12 | infoHash: infoHash, 13 | fileIdx: fileIdx, 14 | sources: sources 15 | }; 16 | } 17 | 18 | function createTorrent(streamingServerURL, infoHash, fileIdx, sources, seriesInfo) { 19 | if ((!Array.isArray(sources) || sources.length === 0) && (fileIdx !== null && isFinite(fileIdx))) { 20 | return Promise.resolve(buildTorrent(streamingServerURL, infoHash, fileIdx, sources)); 21 | } 22 | 23 | var body = { 24 | torrent: { 25 | infoHash: infoHash, 26 | } 27 | }; 28 | 29 | if (Array.isArray(sources) && sources.length > 0) { 30 | body.peerSearch = { 31 | sources: ['dht:' + infoHash].concat(sources).filter(function(source, index, sources) { 32 | return sources.indexOf(source) === index; 33 | }), 34 | min: 40, 35 | max: 200 36 | }; 37 | } 38 | 39 | if (fileIdx === null || !isFinite(fileIdx)) { 40 | body.guessFileIdx = {}; 41 | if (seriesInfo) { 42 | if (seriesInfo.season !== null && isFinite(seriesInfo.season)) { 43 | body.guessFileIdx.season = seriesInfo.season; 44 | } 45 | if (seriesInfo.episode !== null && isFinite(seriesInfo.episode)) { 46 | body.guessFileIdx.episode = seriesInfo.episode; 47 | } 48 | } 49 | } else { 50 | body.guessFileIdx = false; 51 | } 52 | 53 | return fetch(url.resolve(streamingServerURL, '/' + encodeURIComponent(infoHash) + '/create'), { 54 | method: 'POST', 55 | headers: { 56 | 'content-type': 'application/json' 57 | }, 58 | body: JSON.stringify(body) 59 | }).then(function(resp) { 60 | if (resp.ok) { 61 | return resp.json(); 62 | } 63 | 64 | throw new Error(resp.status + ' (' + resp.statusText + ')'); 65 | }).then(function(resp) { 66 | return buildTorrent(streamingServerURL, infoHash, body.guessFileIdx ? resp.guessedFileIdx : fileIdx, body.peerSearch ? body.peerSearch.sources : []); 67 | }); 68 | } 69 | 70 | module.exports = createTorrent; 71 | -------------------------------------------------------------------------------- /src/StremioVideo/selectVideoImplementation.js: -------------------------------------------------------------------------------- 1 | var ChromecastSenderVideo = require('../ChromecastSenderVideo'); 2 | var ShellVideo = require('../ShellVideo'); 3 | var HTMLVideo = require('../HTMLVideo'); 4 | var TizenVideo = require('../TizenVideo'); 5 | var TitanVideo = require('../TitanVideo'); 6 | var VidaaVideo = require('../VidaaVideo'); 7 | var WebOsVideo = require('../WebOsVideo'); 8 | var IFrameVideo = require('../IFrameVideo'); 9 | var YouTubeVideo = require('../YouTubeVideo'); 10 | var withStreamingServer = require('../withStreamingServer'); 11 | var withHTMLSubtitles = require('../withHTMLSubtitles'); 12 | var withVideoParams = require('../withVideoParams'); 13 | 14 | function selectVideoImplementation(commandArgs, options) { 15 | if (!commandArgs.stream || typeof commandArgs.stream.externalUrl === 'string') { 16 | return null; 17 | } 18 | 19 | if (options.chromecastTransport && options.chromecastTransport.getCastState() === cast.framework.CastState.CONNECTED) { 20 | return ChromecastSenderVideo; 21 | } 22 | 23 | if (typeof commandArgs.stream.ytId === 'string') { 24 | return withVideoParams(withHTMLSubtitles(YouTubeVideo)); 25 | } 26 | 27 | if (typeof commandArgs.stream.playerFrameUrl === 'string') { 28 | return withVideoParams(IFrameVideo); 29 | } 30 | 31 | if (options.shellTransport) { 32 | return withStreamingServer(withHTMLSubtitles(ShellVideo)); 33 | } 34 | 35 | if (typeof commandArgs.streamingServerURL === 'string') { 36 | if (commandArgs.platform === 'Tizen') { 37 | return withStreamingServer(withHTMLSubtitles(TizenVideo)); 38 | } 39 | if (commandArgs.platform === 'webOS') { 40 | return withStreamingServer(withHTMLSubtitles(WebOsVideo)); 41 | } 42 | if (commandArgs.platform === 'Titan' || commandArgs.platform === 'NetTV') { 43 | return withStreamingServer(withHTMLSubtitles(TitanVideo)); 44 | } 45 | if (commandArgs.platform === 'Vidaa') { 46 | return withStreamingServer(withHTMLSubtitles(VidaaVideo)); 47 | } 48 | return withStreamingServer(withHTMLSubtitles(HTMLVideo)); 49 | } 50 | 51 | if (typeof commandArgs.stream.url === 'string') { 52 | if (commandArgs.platform === 'Tizen') { 53 | return withVideoParams(withHTMLSubtitles(TizenVideo)); 54 | } 55 | if (commandArgs.platform === 'webOS') { 56 | return withVideoParams(withHTMLSubtitles(WebOsVideo)); 57 | } 58 | if (commandArgs.platform === 'Titan' || commandArgs.platform === 'NetTV') { 59 | return withVideoParams(withHTMLSubtitles(TitanVideo)); 60 | } 61 | if (commandArgs.platform === 'Vidaa') { 62 | return withVideoParams(withHTMLSubtitles(VidaaVideo)); 63 | } 64 | return withVideoParams(withHTMLSubtitles(HTMLVideo)); 65 | } 66 | 67 | return null; 68 | } 69 | 70 | module.exports = selectVideoImplementation; 71 | -------------------------------------------------------------------------------- /src/mediaCapabilities.js: -------------------------------------------------------------------------------- 1 | var VIDEO_CODEC_CONFIGS = [ 2 | { 3 | codec: 'h264', 4 | force: window.chrome || window.cast, 5 | mime: 'video/mp4; codecs="avc1.42E01E"', 6 | }, 7 | { 8 | codec: 'h265', 9 | // Disabled because chrome only has partial support for h265/hvec, 10 | // force: window.chrome || window.cast, 11 | mime: 'video/mp4; codecs="hev1.1.6.L150.B0"', 12 | aliases: ['hevc'] 13 | }, 14 | { 15 | codec: 'vp8', 16 | mime: 'video/mp4; codecs="vp8"' 17 | }, 18 | { 19 | codec: 'vp9', 20 | mime: 'video/mp4; codecs="vp9"' 21 | } 22 | ]; 23 | 24 | var AUDIO_CODEC_CONFIGS = [ 25 | { 26 | codec: 'aac', 27 | mime: 'audio/mp4; codecs="mp4a.40.2"' 28 | }, 29 | { 30 | codec: 'mp3', 31 | mime: 'audio/mp4; codecs="mp3"' 32 | }, 33 | { 34 | codec: 'ac3', 35 | mime: 'audio/mp4; codecs="ac-3"' 36 | }, 37 | { 38 | codec: 'eac3', 39 | mime: 'audio/mp4; codecs="ec-3"' 40 | }, 41 | { 42 | codec: 'vorbis', 43 | mime: 'audio/mp4; codecs="vorbis"' 44 | }, 45 | { 46 | codec: 'opus', 47 | mime: 'audio/mp4; codecs="opus"' 48 | } 49 | ]; 50 | 51 | function canPlay(config, options) { 52 | return config.force || typeof options.mediaElement.canPlayType === 'function' && options.mediaElement.canPlayType(config.mime) 53 | ? [config.codec].concat(config.aliases || []) 54 | : []; 55 | } 56 | 57 | function getMaxAudioChannels() { 58 | if (/firefox/i.test(window.navigator.userAgent)) { 59 | return 6; 60 | } 61 | 62 | if (!window.AudioContext || window.chrome || window.cast) { 63 | return 2; 64 | } 65 | 66 | var maxChannelCount = new AudioContext().destination.maxChannelCount; 67 | return maxChannelCount > 0 ? maxChannelCount : 2; 68 | } 69 | 70 | function getMediaCapabilities() { 71 | var mediaElement = document.createElement('video'); 72 | var formats = ['mp4']; 73 | if (window.chrome || window.cast) { 74 | formats.push('matroska,webm'); 75 | } 76 | var videoCodecs = VIDEO_CODEC_CONFIGS 77 | .map(function(config) { 78 | return canPlay(config, { mediaElement: mediaElement }); 79 | }) 80 | .reduce(function(result, value) { 81 | return result.concat(value); 82 | }, []); 83 | var audioCodecs = AUDIO_CODEC_CONFIGS 84 | .map(function(config) { 85 | return canPlay(config, { mediaElement: mediaElement }); 86 | }) 87 | .reduce(function(result, value) { 88 | return result.concat(value); 89 | }, []); 90 | var maxAudioChannels = getMaxAudioChannels(); 91 | return { 92 | formats: formats, 93 | videoCodecs: videoCodecs, 94 | audioCodecs: audioCodecs, 95 | maxAudioChannels: maxAudioChannels 96 | }; 97 | } 98 | 99 | module.exports = getMediaCapabilities(); 100 | -------------------------------------------------------------------------------- /src/withHTMLSubtitles/subtitlesParser.js: -------------------------------------------------------------------------------- 1 | var VTTJS = require('vtt.js'); 2 | var binarySearchUpperBound = require('./binarySearchUpperBound'); 3 | 4 | var CRITICAL_ERROR_CODE = 0; 5 | 6 | function parse(text) { 7 | return new Promise(function(resolve, reject) { 8 | var parser = new VTTJS.WebVTT.Parser(window, VTTJS.WebVTT.StringDecoder()); 9 | var errors = []; 10 | var cues = []; 11 | var cuesByTime = {}; 12 | 13 | parser.oncue = function(c) { 14 | var cue = { 15 | startTime: (c.startTime * 1000) | 0, 16 | endTime: (c.endTime * 1000) | 0, 17 | text: c.text 18 | }; 19 | cues.push(cue); 20 | cuesByTime[cue.startTime] = cuesByTime[cue.startTime] || []; 21 | cuesByTime[cue.endTime] = cuesByTime[cue.endTime] || []; 22 | }; 23 | 24 | parser.onparsingerror = function(error) { 25 | if (error.code === CRITICAL_ERROR_CODE) { 26 | parser.oncue = null; 27 | parser.onparsingerror = null; 28 | parser.onflush = null; 29 | reject(error); 30 | } else { 31 | console.warn('Subtitles parsing error', error); 32 | errors.push(error); 33 | } 34 | }; 35 | 36 | parser.onflush = function() { 37 | cuesByTime.times = Object.keys(cuesByTime) 38 | .map(function(time) { 39 | return parseInt(time, 10); 40 | }) 41 | .sort(function(t1, t2) { 42 | return t1 - t2; 43 | }); 44 | for (var i = 0; i < cues.length; i++) { 45 | cuesByTime[cues[i].startTime].push(cues[i]); 46 | var startTimeIndex = binarySearchUpperBound(cuesByTime.times, cues[i].startTime); 47 | for (var j = startTimeIndex + 1; j < cuesByTime.times.length; j++) { 48 | if (cues[i].endTime <= cuesByTime.times[j]) { 49 | break; 50 | } 51 | 52 | cuesByTime[cuesByTime.times[j]].push(cues[i]); 53 | } 54 | } 55 | 56 | for (var k = 0; k < cuesByTime.times.length; k++) { 57 | cuesByTime[cuesByTime.times[k]].sort(function(c1, c2) { 58 | return c1.startTime - c2.startTime || 59 | c1.endTime - c2.endTime; 60 | }); 61 | } 62 | 63 | parser.oncue = null; 64 | parser.onparsingerror = null; 65 | parser.onflush = null; 66 | // we may have multiple parsing errors here, but will only respond with the first 67 | // if subtitle cues are available, we will not reject the promise 68 | if (cues.length === 0 && errors.length) { 69 | reject(errors[0]); 70 | } else if (cuesByTime.times.length === 0) { 71 | reject(new Error('Missing subtitle track cues')); 72 | } else { 73 | resolve(cuesByTime); 74 | } 75 | }; 76 | 77 | parser.parse(text); 78 | }); 79 | } 80 | 81 | module.exports = { 82 | parse: parse 83 | }; 84 | -------------------------------------------------------------------------------- /src/withStreamingServer/convertStream.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | var magnet = require('magnet-uri'); 3 | var createTorrent = require('./createTorrent'); 4 | 5 | function buildProxyUrl(streamingServerURL, streamURL, requestHeaders, responseHeaders) { 6 | var parsedStreamURL = new URL(streamURL); 7 | var proxyOptions = new URLSearchParams(); 8 | proxyOptions.set('d', parsedStreamURL.origin); 9 | Object.entries(requestHeaders).forEach(function(entry) { 10 | proxyOptions.append('h', entry[0] + ':' + entry[1]); 11 | }); 12 | Object.entries(responseHeaders).forEach(function(entry) { 13 | proxyOptions.append('r', entry[0] + ':' + entry[1]); 14 | }); 15 | return url.resolve(streamingServerURL, '/proxy/' + proxyOptions.toString() + parsedStreamURL.pathname) + parsedStreamURL.search; 16 | } 17 | 18 | function convertStream(streamingServerURL, stream, seriesInfo, streamingServerSettings) { 19 | return new Promise(function(resolve, reject) { 20 | if (typeof stream.url === 'string') { 21 | if (stream.url.indexOf('magnet:') === 0) { 22 | var parsedMagnetURI; 23 | try { 24 | parsedMagnetURI = magnet.decode(stream.url); 25 | if (!parsedMagnetURI || typeof parsedMagnetURI.infoHash !== 'string') { 26 | throw new Error('Failed to decode magnet url'); 27 | } 28 | } catch (error) { 29 | reject(error); 30 | return; 31 | } 32 | 33 | var sources = Array.isArray(parsedMagnetURI.announce) ? 34 | parsedMagnetURI.announce.map(function(source) { 35 | return 'tracker:' + source; 36 | }) 37 | : 38 | []; 39 | createTorrent(streamingServerURL, parsedMagnetURI.infoHash, null, sources, seriesInfo) 40 | .then(function(torrent) { 41 | resolve({ url: torrent.url, infoHash: torrent.infoHash, fileIdx: torrent.fileIdx }); 42 | }) 43 | .catch(function(error) { 44 | reject(error); 45 | }); 46 | } else { 47 | var proxyStreamsEnabled = streamingServerSettings && streamingServerSettings.proxyStreamsEnabled; 48 | var proxyHeaders = stream.behaviorHints && stream.behaviorHints.proxyHeaders; 49 | if (proxyStreamsEnabled || proxyHeaders) { 50 | var requestHeaders = proxyHeaders && proxyHeaders.request ? proxyHeaders.request : {}; 51 | var responseHeaders = proxyHeaders && proxyHeaders.response ? proxyHeaders.response : {}; 52 | resolve({ url: buildProxyUrl(streamingServerURL, stream.url, requestHeaders, responseHeaders) }); 53 | } else { 54 | resolve({ url: stream.url }); 55 | } 56 | } 57 | 58 | return; 59 | } 60 | 61 | if (typeof stream.infoHash === 'string') { 62 | createTorrent(streamingServerURL, stream.infoHash, stream.fileIdx, stream.announce, seriesInfo) 63 | .then(function(torrent) { 64 | resolve({ url: torrent.url, infoHash: torrent.infoHash, fileIdx: torrent.fileIdx }); 65 | }) 66 | .catch(function(error) { 67 | reject(error); 68 | }); 69 | 70 | return; 71 | } 72 | 73 | reject(new Error('Stream cannot be converted')); 74 | }); 75 | } 76 | 77 | module.exports = convertStream; 78 | -------------------------------------------------------------------------------- /src/withStreamingServer/fetchVideoParams.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | function fetchOpensubtitlesParams(streamingServerURL, mediaURL, behaviorHints) { 4 | var hash = behaviorHints && typeof behaviorHints.videoHash === 'string' ? behaviorHints.videoHash : null; 5 | var size = behaviorHints && isFinite(behaviorHints.videoSize) ? behaviorHints.videoSize : null; 6 | if (typeof hash === 'string' && size !== null && isFinite(size)) { 7 | return Promise.resolve({ hash: hash, size: size }); 8 | } 9 | 10 | var queryParams = new URLSearchParams([['videoUrl', mediaURL]]); 11 | return fetch(url.resolve(streamingServerURL, '/opensubHash?' + queryParams.toString())) 12 | .then(function(resp) { 13 | if (resp.ok) { 14 | return resp.json(); 15 | } 16 | 17 | throw new Error(resp.status + ' (' + resp.statusText + ')'); 18 | }) 19 | .then(function(resp) { 20 | if (resp.error) { 21 | throw new Error(resp.error); 22 | } 23 | 24 | return { 25 | hash: typeof hash === 'string' ? 26 | hash 27 | : 28 | resp.result && typeof resp.result.hash === 'string' ? 29 | resp.result.hash 30 | : 31 | null, 32 | size: size !== null && isFinite(size) ? 33 | size 34 | : 35 | resp.result && typeof resp.result.size ? 36 | resp.result.size 37 | : 38 | null 39 | }; 40 | }); 41 | } 42 | 43 | function fetchFilename(streamingServerURL, mediaURL, infoHash, fileIdx, behaviorHints) { 44 | if (behaviorHints && typeof behaviorHints.filename === 'string') { 45 | return Promise.resolve(behaviorHints.filename); 46 | } 47 | 48 | if (infoHash) { 49 | return fetch(url.resolve(streamingServerURL, '/' + encodeURIComponent(infoHash) + '/' + encodeURIComponent(fileIdx) + '/stats.json')) 50 | .then(function(resp) { 51 | if (resp.ok) { 52 | return resp.json(); 53 | } 54 | 55 | throw new Error(resp.status + ' (' + resp.statusText + ')'); 56 | }) 57 | .then(function(resp) { 58 | if (!resp || typeof resp.streamName !== 'string') { 59 | throw new Error('Could not retrieve filename from torrent'); 60 | } 61 | 62 | return resp.streamName; 63 | }); 64 | } 65 | 66 | return Promise.resolve(decodeURIComponent(mediaURL.split('/').pop())); 67 | } 68 | 69 | function fetchVideoParams(streamingServerURL, mediaURL, infoHash, fileIdx, behaviorHints) { 70 | return Promise.allSettled([ 71 | fetchOpensubtitlesParams(streamingServerURL, mediaURL, behaviorHints), 72 | fetchFilename(streamingServerURL, mediaURL, infoHash, fileIdx, behaviorHints) 73 | ]).then(function(results) { 74 | var result = { hash: null, size: null, filename: null }; 75 | 76 | if (results[0].status === 'fulfilled') { 77 | result.hash = results[0].value.hash; 78 | result.size = results[0].value.size; 79 | } else if (results[0].reason) { 80 | // eslint-disable-next-line no-console 81 | console.error(results[0].reason); 82 | } 83 | 84 | if (results[1].status === 'fulfilled') { 85 | result.filename = results[1].value; 86 | } else if (results[1].reason) { 87 | // eslint-disable-next-line no-console 88 | console.error(results[1].reason); 89 | } 90 | 91 | return result; 92 | }); 93 | } 94 | 95 | module.exports = fetchVideoParams; 96 | -------------------------------------------------------------------------------- /src/StremioVideo/StremioVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var selectVideoImplementation = require('./selectVideoImplementation'); 5 | var platform = require('../platform'); 6 | var ERROR = require('../error'); 7 | 8 | function StremioVideo() { 9 | var video = null; 10 | var events = new EventEmitter(); 11 | var destroyed = false; 12 | 13 | this.on = function(eventName, listener) { 14 | if (destroyed) { 15 | throw new Error('Video is destroyed'); 16 | } 17 | 18 | events.on(eventName, listener); 19 | }; 20 | this.dispatch = function(action, options) { 21 | if (destroyed) { 22 | throw new Error('Video is destroyed'); 23 | } 24 | 25 | if (action) { 26 | action = deepFreeze(cloneDeep(action)); 27 | options = options || {}; 28 | if (action.type === 'command' && action.commandName === 'load' && action.commandArgs) { 29 | if (action.commandArgs.platform) { 30 | platform.set(action.commandArgs.platform); 31 | } 32 | var Video = selectVideoImplementation(action.commandArgs, options); 33 | if (video !== null && video.constructor !== Video) { 34 | video.dispatch({ type: 'command', commandName: 'destroy' }); 35 | video = null; 36 | } 37 | if (video === null) { 38 | if (Video === null) { 39 | events.emit('error', Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 40 | error: new Error('No video implementation was selected'), 41 | critical: true, 42 | stream: action.commandArgs.stream 43 | })); 44 | return; 45 | } 46 | 47 | video = new Video(options); 48 | video.on('ended', function() { 49 | events.emit('ended'); 50 | }); 51 | video.on('error', function(args) { 52 | events.emit('error', args); 53 | }); 54 | video.on('propValue', function(propName, propValue) { 55 | events.emit('propValue', propName, propValue); 56 | }); 57 | video.on('propChanged', function(propName, propValue) { 58 | events.emit('propChanged', propName, propValue); 59 | }); 60 | video.on('subtitlesTrackLoaded', function(track) { 61 | events.emit('subtitlesTrackLoaded', track); 62 | }); 63 | video.on('audioTrackLoaded', function(track) { 64 | events.emit('audioTrackLoaded', track); 65 | }); 66 | video.on('extraSubtitlesTrackLoaded', function(track) { 67 | events.emit('extraSubtitlesTrackLoaded', track); 68 | }); 69 | video.on('extraSubtitlesTrackAdded', function(track) { 70 | events.emit('extraSubtitlesTrackAdded', track); 71 | }); 72 | if (Video.manifest.external) { 73 | video.on('implementationChanged', function(manifest) { 74 | events.emit('implementationChanged', manifest); 75 | }); 76 | } else { 77 | events.emit('implementationChanged', Video.manifest); 78 | } 79 | } 80 | } 81 | 82 | if (video !== null) { 83 | try { 84 | video.dispatch(action); 85 | } catch (error) { 86 | // eslint-disable-next-line no-console 87 | console.error(video.constructor.manifest.name, error); 88 | } 89 | } 90 | 91 | if (action.type === 'command' && action.commandName === 'destroy') { 92 | video = null; 93 | } 94 | 95 | return; 96 | } 97 | 98 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 99 | }; 100 | this.destroy = function() { 101 | destroyed = true; 102 | if (video !== null) { 103 | video.dispatch({ type: 'command', commandName: 'destroy' }); 104 | video = null; 105 | } 106 | 107 | events.removeAllListeners(); 108 | }; 109 | } 110 | 111 | StremioVideo.ERROR = ERROR; 112 | 113 | module.exports = StremioVideo; 114 | -------------------------------------------------------------------------------- /src/withVideoParams/withVideoParams.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | 5 | function withVideoParams(Video) { 6 | function VideoWithVideoParams(options) { 7 | options = options || {}; 8 | 9 | var video = new Video(options); 10 | video.on('propValue', onVideoPropEvent.bind(null, 'propValue')); 11 | video.on('propChanged', onVideoPropEvent.bind(null, 'propChanged')); 12 | Video.manifest.events 13 | .filter(function(eventName) { 14 | return !['propValue', 'propChanged'].includes(eventName); 15 | }) 16 | .forEach(function(eventName) { 17 | video.on(eventName, onOtherVideoEvent(eventName)); 18 | }); 19 | 20 | var stream = null; 21 | var events = new EventEmitter(); 22 | var destroyed = false; 23 | var observedProps = { 24 | videoParams: false 25 | }; 26 | 27 | function onVideoPropEvent(eventName, propName, propValue) { 28 | if (propName !== 'videoParams') { 29 | events.emit(eventName, propName, getProp(propName, propValue)); 30 | } 31 | if (propName === 'stream') { 32 | stream = propValue; 33 | onPropChanged('videoParams'); 34 | } 35 | } 36 | function onOtherVideoEvent(eventName) { 37 | return function() { 38 | events.emit.apply(events, [eventName].concat(Array.from(arguments))); 39 | }; 40 | } 41 | function onPropChanged(propName) { 42 | if (observedProps[propName]) { 43 | events.emit('propChanged', propName, getProp(propName, null)); 44 | } 45 | } 46 | function getProp(propName, videoPropValue) { 47 | switch (propName) { 48 | case 'videoParams': { 49 | if (stream === null) { 50 | return null; 51 | } 52 | 53 | var hash = stream.behaviorHints && typeof stream.behaviorHints.videoHash === 'string' ? stream.behaviorHints.videoHash : null; 54 | var size = stream.behaviorHints && stream.behaviorHints.videoSize !== null && isFinite(stream.behaviorHints.videoSize) ? stream.behaviorHints.videoSize : null; 55 | var filename = stream.behaviorHints && typeof stream.behaviorHints.filename === 'string' ? stream.behaviorHints.filename : null; 56 | return { hash: hash, size: size, filename: filename }; 57 | } 58 | default: { 59 | return videoPropValue; 60 | } 61 | } 62 | } 63 | function observeProp(propName) { 64 | switch (propName) { 65 | case 'videoParams': { 66 | events.emit('propValue', propName, getProp(propName, null)); 67 | observedProps[propName] = true; 68 | return true; 69 | } 70 | default: { 71 | return false; 72 | } 73 | } 74 | } 75 | function command(commandName) { 76 | switch (commandName) { 77 | case 'destroy': { 78 | destroyed = true; 79 | video.dispatch({ type: 'command', commandName: 'destroy' }); 80 | events.removeAllListeners(); 81 | return true; 82 | } 83 | default: { 84 | return false; 85 | } 86 | } 87 | } 88 | 89 | this.on = function(eventName, listener) { 90 | if (destroyed) { 91 | throw new Error('Video is destroyed'); 92 | } 93 | 94 | events.on(eventName, listener); 95 | }; 96 | this.dispatch = function(action) { 97 | if (destroyed) { 98 | throw new Error('Video is destroyed'); 99 | } 100 | 101 | if (action) { 102 | action = deepFreeze(cloneDeep(action)); 103 | switch (action.type) { 104 | case 'observeProp': { 105 | if (observeProp(action.propName)) { 106 | return; 107 | } 108 | 109 | break; 110 | } 111 | case 'command': { 112 | if (command(action.commandName, action.commandArgs)) { 113 | return; 114 | } 115 | 116 | break; 117 | } 118 | } 119 | } 120 | 121 | video.dispatch(action); 122 | }; 123 | } 124 | 125 | VideoWithVideoParams.canPlayStream = function(stream, options) { 126 | return Video.canPlayStream(stream, options); 127 | }; 128 | 129 | VideoWithVideoParams.manifest = { 130 | name: Video.manifest.name + 'WithVideoParams', 131 | external: Video.manifest.external, 132 | props: Video.manifest.props.concat(['videoParams']) 133 | .filter(function(value, index, array) { return array.indexOf(value) === index; }), 134 | commands: Video.manifest.commands.concat(['destroy']) 135 | .filter(function(value, index, array) { return array.indexOf(value) === index; }), 136 | events: Video.manifest.events.concat(['propValue', 'propChanged']) 137 | .filter(function(value, index, array) { return array.indexOf(value) === index; }) 138 | }; 139 | 140 | return VideoWithVideoParams; 141 | } 142 | 143 | module.exports = withVideoParams; 144 | -------------------------------------------------------------------------------- /src/IFrameVideo/IFrameVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var ERROR = require('../error'); 5 | 6 | function IFrameVideo(options) { 7 | options = options || {}; 8 | 9 | var containerElement = options.containerElement; 10 | if (!(containerElement instanceof HTMLElement)) { 11 | throw new Error('Container element required to be instance of HTMLElement'); 12 | } 13 | 14 | var iframeElement = document.createElement('iframe'); 15 | iframeElement.style.width = '100%'; 16 | iframeElement.style.height = '100%'; 17 | iframeElement.style.border = 0; 18 | iframeElement.style.backgroundColor = 'black'; 19 | iframeElement.allowFullscreen = false; 20 | iframeElement.allow = 'autoplay'; 21 | containerElement.appendChild(iframeElement); 22 | 23 | var events = new EventEmitter(); 24 | var destroyed = false; 25 | var observedProps = { 26 | stream: false, 27 | loaded: false, 28 | paused: false, 29 | time: false, 30 | duration: false, 31 | buffering: false, 32 | buffered: false, 33 | volume: false, 34 | muted: false, 35 | playbackSpeed: false 36 | }; 37 | 38 | function onMessage(event) { 39 | if (event.source !== iframeElement.contentWindow) { 40 | return; 41 | } 42 | 43 | var data = event.data || event.message; 44 | if (!data || typeof data.event !== 'string') { 45 | return; 46 | } 47 | 48 | var eventName = data.event; 49 | var args = Array.isArray(data.args) ? data.args : []; 50 | events.emit.apply(events, [eventName].concat(args)); 51 | } 52 | function sendMessage(action) { 53 | iframeElement.contentWindow.postMessage(action, '*'); 54 | } 55 | function onError(error) { 56 | events.emit('error', error); 57 | if (error.critical) { 58 | command('unload'); 59 | } 60 | } 61 | function onPropChanged(propName, propValue) { 62 | if (observedProps[propName]) { 63 | events.emit('propChanged', propName, propValue); 64 | } 65 | } 66 | function observeProp(propName) { 67 | if (observedProps.hasOwnProperty(propName)) { 68 | observedProps[propName] = true; 69 | } 70 | } 71 | function command(commandName, commandArgs) { 72 | switch (commandName) { 73 | case 'load': { 74 | command('unload'); 75 | if (commandArgs && commandArgs.stream && typeof commandArgs.stream.playerFrameUrl === 'string') { 76 | window.addEventListener('message', onMessage, false); 77 | iframeElement.onload = function() { 78 | sendMessage({ 79 | type: 'command', 80 | commandName: commandName, 81 | commandArgs: commandArgs 82 | }); 83 | }; 84 | iframeElement.src = commandArgs.stream.playerFrameUrl; 85 | } else { 86 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 87 | critical: true, 88 | stream: commandArgs ? commandArgs.stream : null 89 | })); 90 | } 91 | 92 | return true; 93 | } 94 | case 'unload': { 95 | window.removeEventListener('message', onMessage); 96 | iframeElement.onload = null; 97 | iframeElement.removeAttribute('src'); 98 | onPropChanged('stream', null); 99 | onPropChanged('loaded', null); 100 | onPropChanged('paused', null); 101 | onPropChanged('time', null); 102 | onPropChanged('duration', null); 103 | onPropChanged('buffering', null); 104 | onPropChanged('buffered', null); 105 | onPropChanged('volume', null); 106 | onPropChanged('muted', null); 107 | onPropChanged('playbackSpeed', null); 108 | return true; 109 | } 110 | case 'destroy': { 111 | command('unload'); 112 | destroyed = true; 113 | events.removeAllListeners(); 114 | containerElement.removeChild(iframeElement); 115 | return true; 116 | } 117 | } 118 | } 119 | 120 | this.on = function(eventName, listener) { 121 | if (destroyed) { 122 | throw new Error('Video is destroyed'); 123 | } 124 | 125 | events.on(eventName, listener); 126 | }; 127 | this.dispatch = function(action) { 128 | if (destroyed) { 129 | throw new Error('Video is destroyed'); 130 | } 131 | 132 | if (action) { 133 | action = deepFreeze(cloneDeep(action)); 134 | switch (action.type) { 135 | case 'observeProp': { 136 | observeProp(action.propName); 137 | sendMessage(action); 138 | return; 139 | } 140 | case 'setProp': { 141 | sendMessage(action); 142 | return; 143 | } 144 | case 'command': { 145 | if (!command(action.commandName, action.commandArgs)) { 146 | sendMessage(action); 147 | } 148 | 149 | return; 150 | } 151 | } 152 | } 153 | 154 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 155 | }; 156 | } 157 | 158 | IFrameVideo.canPlayStream = function(stream) { 159 | return Promise.resolve(stream && typeof stream.playerFrameUrl === 'string'); 160 | }; 161 | 162 | IFrameVideo.manifest = { 163 | name: 'IFrameVideo', 164 | external: true, 165 | props: ['stream', 'loaded', 'paused', 'time', 'duration', 'buffering', 'buffered', 'audioTracks', 'selectedAudioTrackId', 'subtitlesTracks', 'selectedSubtitlesTrackId', 'subtitlesOffset', 'subtitlesSize', 'subtitlesTextColor', 'subtitlesBackgroundColor', 'subtitlesOutlineColor', 'volume', 'muted', 'playbackSpeed', 'extraSubtitlesTracks', 'selectedExtraSubtitlesTrackId', 'extraSubtitlesDelay', 'extraSubtitlesSize', 'extraSubtitlesOffset', 'extraSubtitlesTextColor', 'extraSubtitlesBackgroundColor', 'extraSubtitlesOutlineColor'], 166 | commands: ['load', 'unload', 'destroy', 'addExtraSubtitlesTracks'], 167 | events: ['propValue', 'propChanged', 'ended', 'error', 'subtitlesTrackLoaded', 'audioTrackLoaded', 'extraSubtitlesTrackLoaded', 'implementationChanged'] 168 | }; 169 | 170 | module.exports = IFrameVideo; 171 | -------------------------------------------------------------------------------- /src/ChromecastSenderVideo/ChromecastSenderVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var ERROR = require('../error'); 3 | 4 | function ChromecastSenderVideo(options) { 5 | options = options || {}; 6 | 7 | var containerElement = options.containerElement; 8 | if (!(containerElement instanceof HTMLElement)) { 9 | throw new Error('Container element required to be instance of HTMLElement'); 10 | } 11 | 12 | var chromecastTransport = options.chromecastTransport; 13 | if (!chromecastTransport) { 14 | throw new Error('Chromecast transport required'); 15 | } 16 | 17 | var device = chromecastTransport.getCastDevice(); 18 | if (device === null) { 19 | throw new Error('Chromecast session must be started'); 20 | } 21 | 22 | var deviceNameContainerElement = document.createElement('div'); 23 | deviceNameContainerElement.style.display = 'flex'; 24 | deviceNameContainerElement.style.flexDirection = 'row'; 25 | deviceNameContainerElement.style.alignItems = 'center'; 26 | deviceNameContainerElement.style.justifyContent = 'center'; 27 | deviceNameContainerElement.style.width = '100%'; 28 | deviceNameContainerElement.style.height = '100%'; 29 | deviceNameContainerElement.style.backgroundColor = 'black'; 30 | var deviceNameLabelElement = document.createElement('div'); 31 | deviceNameLabelElement.style.flex = 'none'; 32 | deviceNameLabelElement.style.maxWidth = '80%'; 33 | deviceNameLabelElement.style.fontSize = '5vmin'; 34 | deviceNameLabelElement.style.lineHeight = '1.2em'; 35 | deviceNameLabelElement.style.maxHeight = '3.6em'; 36 | deviceNameLabelElement.style.textAlign = 'center'; 37 | deviceNameLabelElement.style.color = '#FFFFFF90'; 38 | deviceNameLabelElement.innerText = 'Casting to ' + device.friendlyName; 39 | deviceNameContainerElement.appendChild(deviceNameLabelElement); 40 | containerElement.appendChild(deviceNameContainerElement); 41 | chromecastTransport.on('message', onMessage); 42 | chromecastTransport.on('message-error', onMessageReceivedError); 43 | 44 | var events = new EventEmitter(); 45 | var destroyed = false; 46 | var observedProps = { 47 | stream: false, 48 | loaded: false, 49 | paused: false, 50 | time: false, 51 | duration: false, 52 | buffering: false, 53 | buffered: false, 54 | audioTracks: false, 55 | selectedAudioTrackId: false, 56 | subtitlesTracks: false, 57 | selectedSubtitlesTrackId: false, 58 | subtitlesOffset: false, 59 | subtitlesSize: false, 60 | subtitlesTextColor: false, 61 | subtitlesBackgroundColor: false, 62 | subtitlesOutlineColor: false, 63 | volume: false, 64 | muted: false, 65 | playbackSpeed: false, 66 | videoParams: false, 67 | extraSubtitlesTracks: false, 68 | selectedExtraSubtitlesTrackId: false, 69 | extraSubtitlesDelay: false, 70 | extraSubtitlesSize: false, 71 | extraSubtitlesOffset: false, 72 | extraSubtitlesTextColor: false, 73 | extraSubtitlesBackgroundColor: false, 74 | extraSubtitlesOutlineColor: false 75 | }; 76 | 77 | function onMessageSendError(error, action) { 78 | events.emit('error', Object.assign({}, ERROR.CHROMECAST_SENDER_VIDEO.MESSAGE_SEND_FAILED, { 79 | error: error, 80 | action: action 81 | })); 82 | } 83 | function onMessageReceivedError(error) { 84 | events.emit('error', Object.assign({}, ERROR.CHROMECAST_SENDER_VIDEO.INVALID_MESSAGE_RECEIVED, { 85 | error: error 86 | })); 87 | } 88 | function onMessage(message) { 89 | if (!message || typeof message.event !== 'string') { 90 | onMessageReceivedError(new Error('Invalid message: ' + message)); 91 | return; 92 | } 93 | 94 | var args = Array.isArray(message.args) ? message.args : []; 95 | events.emit.apply(events, [message.event].concat(args)); 96 | } 97 | function onPropChanged(propName, propValue) { 98 | if (observedProps[propName]) { 99 | events.emit('propChanged', propName, propValue); 100 | } 101 | } 102 | function observeProp(propName) { 103 | if (observedProps.hasOwnProperty(propName)) { 104 | observedProps[propName] = true; 105 | } 106 | } 107 | function command(commandName) { 108 | switch (commandName) { 109 | case 'destroy': { 110 | destroyed = true; 111 | onPropChanged('stream', null); 112 | onPropChanged('loaded', null); 113 | onPropChanged('paused', null); 114 | onPropChanged('time', null); 115 | onPropChanged('duration', null); 116 | onPropChanged('buffering', null); 117 | onPropChanged('buffered', null); 118 | onPropChanged('audioTracks', []); 119 | onPropChanged('selectedAudioTrackId', []); 120 | onPropChanged('subtitlesTracks', []); 121 | onPropChanged('selectedSubtitlesTrackId', null); 122 | onPropChanged('subtitlesOffset', null); 123 | onPropChanged('subtitlesSize', null); 124 | onPropChanged('subtitlesTextColor', null); 125 | onPropChanged('subtitlesBackgroundColor', null); 126 | onPropChanged('subtitlesOutlineColor', null); 127 | onPropChanged('volume', null); 128 | onPropChanged('muted', null); 129 | onPropChanged('playbackSpeed', null); 130 | onPropChanged('videoParams', null); 131 | onPropChanged('extraSubtitlesTracks', []); 132 | onPropChanged('selectedExtraSubtitlesTrackId', null); 133 | onPropChanged('extraSubtitlesDelay', null); 134 | onPropChanged('extraSubtitlesSize', null); 135 | onPropChanged('extraSubtitlesOffset', null); 136 | onPropChanged('extraSubtitlesTextColor', null); 137 | onPropChanged('extraSubtitlesBackgroundColor', null); 138 | onPropChanged('extraSubtitlesOutlineColor', null); 139 | events.removeAllListeners(); 140 | chromecastTransport.off('message', onMessage); 141 | containerElement.removeChild(deviceNameContainerElement); 142 | break; 143 | } 144 | } 145 | } 146 | 147 | this.on = function(eventName, listener) { 148 | if (destroyed) { 149 | throw new Error('Video is destroyed'); 150 | } 151 | 152 | events.on(eventName, listener); 153 | }; 154 | this.dispatch = function(action) { 155 | if (destroyed) { 156 | throw new Error('Video is destroyed'); 157 | } 158 | 159 | if (action) { 160 | switch (action.type) { 161 | case 'observeProp': { 162 | observeProp(action.propName); 163 | chromecastTransport.sendMessage(action).catch(function(error) { 164 | onMessageSendError(error, action); 165 | }); 166 | return; 167 | } 168 | case 'setProp': { 169 | chromecastTransport.sendMessage(action).catch(function(error) { 170 | onMessageSendError(error, action); 171 | }); 172 | return; 173 | } 174 | case 'command': { 175 | command(action.commandName, action.commandArgs); 176 | chromecastTransport.sendMessage(action).catch(function(error) { 177 | onMessageSendError(error, action); 178 | }); 179 | return; 180 | } 181 | } 182 | } 183 | 184 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 185 | }; 186 | } 187 | 188 | ChromecastSenderVideo.canPlayStream = function() { 189 | return Promise.resolve(true); 190 | }; 191 | 192 | ChromecastSenderVideo.manifest = { 193 | name: 'ChromecastSenderVideo', 194 | external: true, 195 | props: ['stream', 'loaded', 'paused', 'time', 'duration', 'buffering', 'buffered', 'audioTracks', 'selectedAudioTrackId', 'subtitlesTracks', 'selectedSubtitlesTrackId', 'subtitlesOffset', 'subtitlesSize', 'subtitlesTextColor', 'subtitlesBackgroundColor', 'subtitlesOutlineColor', 'volume', 'muted', 'playbackSpeed', 'videoParams', 'extraSubtitlesTracks', 'selectedExtraSubtitlesTrackId', 'extraSubtitlesDelay', 'extraSubtitlesSize', 'extraSubtitlesOffset', 'extraSubtitlesTextColor', 'extraSubtitlesBackgroundColor', 'extraSubtitlesOutlineColor'], 196 | commands: ['load', 'unload', 'destroy', 'addExtraSubtitlesTracks'], 197 | events: ['propValue', 'propChanged', 'ended', 'error', 'subtitlesTrackLoaded', 'audioTrackLoaded', 'extraSubtitlesTrackLoaded', 'implementationChanged'] 198 | }; 199 | 200 | module.exports = ChromecastSenderVideo; 201 | -------------------------------------------------------------------------------- /src/YouTubeVideo/YouTubeVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var ERROR = require('../error'); 5 | 6 | function YouTubeVideo(options) { 7 | options = options || {}; 8 | 9 | var timeChangedTimeout = options.timeChangedTimeout !== null && isFinite(options.timeChangedTimeout) ? parseInt(options.timeChangedTimeout, 10) : 100; 10 | 11 | var containerElement = options.containerElement; 12 | if (!(containerElement instanceof HTMLElement)) { 13 | throw new Error('Container element required to be instance of HTMLElement'); 14 | } 15 | 16 | var apiScriptElement = document.createElement('script'); 17 | apiScriptElement.type = 'text/javascript'; 18 | apiScriptElement.src = 'https://www.youtube.com/iframe_api'; 19 | apiScriptElement.onload = onAPILoaded; 20 | apiScriptElement.onerror = onAPIError; 21 | containerElement.appendChild(apiScriptElement); 22 | var videoContainerElement = document.createElement('div'); 23 | videoContainerElement.style.width = '100%'; 24 | videoContainerElement.style.height = '100%'; 25 | videoContainerElement.style.backgroundColor = 'black'; 26 | containerElement.appendChild(videoContainerElement); 27 | var timeChangedIntervalId = window.setInterval(function() { 28 | onPropChanged('time'); 29 | onPropChanged('volume'); 30 | onPropChanged('muted'); 31 | onPropChanged('playbackSpeed'); 32 | }, timeChangedTimeout); 33 | 34 | var video = null; 35 | var ready = false; 36 | var pendingLoadArgs = null; 37 | var events = new EventEmitter(); 38 | var destroyed = false; 39 | var stream = null; 40 | var selectedSubtitlesTrackId = null; 41 | var observedProps = { 42 | stream: false, 43 | loaded: false, 44 | paused: false, 45 | time: false, 46 | duration: false, 47 | buffering: false, 48 | volume: false, 49 | muted: false, 50 | playbackSpeed: false, 51 | subtitlesTracks: false, 52 | selectedSubtitlesTrackId: false 53 | }; 54 | 55 | function onAPIError() { 56 | if (destroyed) { 57 | return; 58 | } 59 | 60 | onError(Object.assign({}, ERROR.YOUTUBE_VIDEO.API_LOAD_FAILED, { 61 | critical: true 62 | })); 63 | } 64 | function onAPILoaded() { 65 | if (destroyed) { 66 | return; 67 | } 68 | 69 | if (!YT || typeof YT.ready !== 'function') { 70 | onAPIError(); 71 | return; 72 | } 73 | 74 | YT.ready(function() { 75 | if (destroyed) { 76 | return; 77 | } 78 | 79 | if (!YT || !YT.PlayerState || typeof YT.Player !== 'function') { 80 | onAPIError(); 81 | return; 82 | } 83 | 84 | video = new YT.Player(videoContainerElement, { 85 | width: '100%', 86 | height: '100%', 87 | playerVars: { 88 | autoplay: 1, 89 | cc_load_policy: 3, 90 | controls: 0, 91 | disablekb: 1, 92 | enablejsapi: 1, 93 | fs: 0, 94 | iv_load_policy: 3, 95 | loop: 0, 96 | modestbranding: 1, 97 | playsinline: 1, 98 | rel: 0 99 | }, 100 | events: { 101 | onError: onVideoError, 102 | onReady: onVideoReady, 103 | onApiChange: onVideoAPIChange, 104 | onStateChange: onVideoStateChange 105 | } 106 | }); 107 | }); 108 | } 109 | function onVideoError(videoError) { 110 | if (destroyed) { 111 | return; 112 | } 113 | 114 | var error; 115 | switch (videoError.data) { 116 | case 2: { 117 | error = ERROR.YOUTUBE_VIDEO.INVALID_PARAMETER; 118 | break; 119 | } 120 | case 5: { 121 | error = ERROR.YOUTUBE_VIDEO.HTML5_VIDEO; 122 | break; 123 | } 124 | case 100: { 125 | error = ERROR.YOUTUBE_VIDEO.VIDEO_NOT_FOUND; 126 | break; 127 | } 128 | case 101: 129 | case 150: { 130 | error = ERROR.YOUTUBE_VIDEO.VIDEO_NOT_EMBEDDABLE; 131 | break; 132 | } 133 | default: { 134 | error = ERROR.UNKNOWN_ERROR; 135 | } 136 | } 137 | onError(Object.assign({}, error, { 138 | critical: true, 139 | error: videoError 140 | })); 141 | } 142 | function onVideoReady() { 143 | if (destroyed) { 144 | return; 145 | } 146 | 147 | ready = true; 148 | if (pendingLoadArgs !== null) { 149 | command('load', pendingLoadArgs); 150 | pendingLoadArgs = null; 151 | } 152 | } 153 | function onVideoAPIChange() { 154 | if (destroyed) { 155 | return; 156 | } 157 | 158 | if (typeof video.loadModule === 'function') { 159 | video.loadModule('captions'); 160 | } 161 | if (typeof video.setOption === 'function') { 162 | video.setOption('captions', 'track', {}); 163 | } 164 | onPropChanged('paused'); 165 | onPropChanged('time'); 166 | onPropChanged('duration'); 167 | onPropChanged('buffering'); 168 | onPropChanged('volume'); 169 | onPropChanged('muted'); 170 | onPropChanged('playbackSpeed'); 171 | onPropChanged('subtitlesTracks'); 172 | onPropChanged('selectedSubtitlesTrackId'); 173 | } 174 | function onVideoStateChange(state) { 175 | onPropChanged('buffering'); 176 | switch (state.data) { 177 | case YT.PlayerState.ENDED: { 178 | onEnded(); 179 | break; 180 | } 181 | case YT.PlayerState.CUED: 182 | case YT.PlayerState.UNSTARTED: 183 | case YT.PlayerState.PAUSED: 184 | case YT.PlayerState.PLAYING: { 185 | onPropChanged('paused'); 186 | onPropChanged('time'); 187 | onPropChanged('duration'); 188 | break; 189 | } 190 | } 191 | } 192 | function getProp(propName) { 193 | switch (propName) { 194 | case 'stream': { 195 | return stream; 196 | } 197 | case 'loaded': { 198 | if (stream === null) { 199 | return null; 200 | } 201 | 202 | return true; 203 | } 204 | case 'paused': { 205 | if (stream === null || typeof video.getPlayerState !== 'function') { 206 | return null; 207 | } 208 | 209 | return video.getPlayerState() !== YT.PlayerState.PLAYING; 210 | } 211 | case 'time': { 212 | if (stream === null || typeof video.getCurrentTime !== 'function' || video.getCurrentTime() === null || !isFinite(video.getCurrentTime())) { 213 | return null; 214 | } 215 | 216 | return Math.floor(video.getCurrentTime() * 1000); 217 | } 218 | case 'duration': { 219 | if (stream === null || typeof video.getDuration !== 'function' || video.getDuration() === null || !isFinite(video.getDuration())) { 220 | return null; 221 | } 222 | 223 | return Math.floor(video.getDuration() * 1000); 224 | } 225 | case 'buffering': { 226 | if (stream === null || typeof video.getPlayerState !== 'function') { 227 | return null; 228 | } 229 | 230 | return video.getPlayerState() === YT.PlayerState.BUFFERING; 231 | } 232 | case 'volume': { 233 | if (stream === null || typeof video.getVolume !== 'function' || video.getVolume() === null || !isFinite(video.getVolume())) { 234 | return null; 235 | } 236 | 237 | return video.getVolume(); 238 | } 239 | case 'muted': { 240 | if (stream === null || typeof video.isMuted !== 'function') { 241 | return null; 242 | } 243 | 244 | return video.isMuted(); 245 | } 246 | case 'playbackSpeed': { 247 | if (stream === null || typeof video.getPlaybackRate !== 'function' || video.getPlaybackRate() === null || !isFinite(video.getPlaybackRate())) { 248 | return null; 249 | } 250 | 251 | return video.getPlaybackRate(); 252 | } 253 | case 'subtitlesTracks': { 254 | if (stream === null || typeof video.getOption !== 'function') { 255 | return []; 256 | } 257 | 258 | return (video.getOption('captions', 'tracklist') || []) 259 | .filter(function(track) { 260 | return track && typeof track.languageCode === 'string'; 261 | }) 262 | .map(function(track, index) { 263 | return Object.freeze({ 264 | id: 'EMBEDDED_' + String(index), 265 | lang: track.languageCode, 266 | label: typeof track.displayName === 'string' ? track.displayName : track.languageCode, 267 | origin: 'EMBEDDED', 268 | embedded: true 269 | }); 270 | }); 271 | } 272 | case 'selectedSubtitlesTrackId': { 273 | if (stream === null) { 274 | return null; 275 | } 276 | 277 | return selectedSubtitlesTrackId; 278 | } 279 | default: { 280 | return null; 281 | } 282 | } 283 | } 284 | function onError(error) { 285 | events.emit('error', error); 286 | if (error.critical) { 287 | command('unload'); 288 | } 289 | } 290 | function onEnded() { 291 | events.emit('ended'); 292 | } 293 | function onPropChanged(propName) { 294 | if (observedProps[propName]) { 295 | events.emit('propChanged', propName, getProp(propName)); 296 | } 297 | } 298 | function observeProp(propName) { 299 | if (observedProps.hasOwnProperty(propName)) { 300 | events.emit('propValue', propName, getProp(propName)); 301 | observedProps[propName] = true; 302 | } 303 | } 304 | function setProp(propName, propValue) { 305 | switch (propName) { 306 | case 'paused': { 307 | if (stream !== null) { 308 | propValue ? 309 | typeof video.pauseVideo === 'function' && video.pauseVideo() 310 | : 311 | typeof video.playVideo === 'function' && video.playVideo(); 312 | } 313 | 314 | break; 315 | } 316 | case 'time': { 317 | if (stream !== null && typeof video.seekTo === 'function' && propValue !== null && isFinite(propValue)) { 318 | video.seekTo(parseInt(propValue, 10) / 1000); 319 | } 320 | 321 | break; 322 | } 323 | case 'volume': { 324 | if (stream !== null && propValue !== null && isFinite(propValue)) { 325 | if (typeof video.unMute === 'function') { 326 | video.unMute(); 327 | } 328 | if (typeof video.setVolume === 'function') { 329 | video.setVolume(Math.max(0, Math.min(100, parseInt(propValue, 10)))); 330 | } 331 | onPropChanged('muted'); 332 | onPropChanged('volume'); 333 | } 334 | 335 | break; 336 | } 337 | case 'muted': { 338 | if (stream !== null) { 339 | propValue ? 340 | typeof video.mute === 'function' && video.mute() 341 | : 342 | typeof video.unMute === 'function' && video.unMute(); 343 | onPropChanged('muted'); 344 | } 345 | 346 | break; 347 | } 348 | case 'playbackSpeed': { 349 | if (stream !== null && typeof video.setPlaybackRate === 'function' && isFinite(propValue)) { 350 | video.setPlaybackRate(propValue); 351 | onPropChanged('playbackSpeed'); 352 | } 353 | 354 | break; 355 | } 356 | case 'selectedSubtitlesTrackId': { 357 | if (stream !== null) { 358 | selectedSubtitlesTrackId = null; 359 | var selecterdTrack = getProp('subtitlesTracks') 360 | .find(function(track) { 361 | return track.id === propValue; 362 | }); 363 | if (typeof video.setOption === 'function') { 364 | if (selecterdTrack) { 365 | selectedSubtitlesTrackId = selecterdTrack.id; 366 | video.setOption('captions', 'track', { 367 | languageCode: selecterdTrack.lang 368 | }); 369 | events.emit('subtitlesTrackLoaded', selecterdTrack); 370 | } else { 371 | video.setOption('captions', 'track', {}); 372 | } 373 | } 374 | onPropChanged('selectedSubtitlesTrackId'); 375 | } 376 | 377 | break; 378 | } 379 | } 380 | } 381 | function command(commandName, commandArgs) { 382 | switch (commandName) { 383 | case 'load': { 384 | command('unload'); 385 | if (commandArgs && commandArgs.stream && typeof commandArgs.stream.ytId === 'string') { 386 | if (ready) { 387 | stream = commandArgs.stream; 388 | onPropChanged('stream'); 389 | onPropChanged('loaded'); 390 | var autoplay = typeof commandArgs.autoplay === 'boolean' ? commandArgs.autoplay : true; 391 | var time = commandArgs.time !== null && isFinite(commandArgs.time) ? parseInt(commandArgs.time, 10) / 1000 : 0; 392 | if (autoplay && typeof video.loadVideoById === 'function') { 393 | video.loadVideoById({ 394 | videoId: commandArgs.stream.ytId, 395 | startSeconds: time 396 | }); 397 | } else if (typeof video.cueVideoById === 'function') { 398 | video.cueVideoById({ 399 | videoId: commandArgs.stream.ytId, 400 | startSeconds: time 401 | }); 402 | } 403 | onPropChanged('paused'); 404 | onPropChanged('time'); 405 | onPropChanged('duration'); 406 | onPropChanged('buffering'); 407 | onPropChanged('volume'); 408 | onPropChanged('muted'); 409 | onPropChanged('playbackSpeed'); 410 | onPropChanged('subtitlesTracks'); 411 | onPropChanged('selectedSubtitlesTrackId'); 412 | } else { 413 | pendingLoadArgs = commandArgs; 414 | } 415 | } else { 416 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 417 | critical: true, 418 | stream: commandArgs ? commandArgs.stream : null 419 | })); 420 | } 421 | 422 | break; 423 | } 424 | case 'unload': { 425 | pendingLoadArgs = null; 426 | stream = null; 427 | onPropChanged('stream'); 428 | onPropChanged('loaded'); 429 | selectedSubtitlesTrackId = null; 430 | if (ready && typeof video.stopVideo === 'function') { 431 | video.stopVideo(); 432 | } 433 | onPropChanged('paused'); 434 | onPropChanged('time'); 435 | onPropChanged('duration'); 436 | onPropChanged('buffering'); 437 | onPropChanged('volume'); 438 | onPropChanged('muted'); 439 | onPropChanged('playbackSpeed'); 440 | onPropChanged('subtitlesTracks'); 441 | onPropChanged('selectedSubtitlesTrackId'); 442 | break; 443 | } 444 | case 'destroy': { 445 | command('unload'); 446 | destroyed = true; 447 | events.removeAllListeners(); 448 | clearInterval(timeChangedIntervalId); 449 | if (ready && typeof video.destroy === 'function') { 450 | video.destroy(); 451 | } 452 | containerElement.removeChild(apiScriptElement); 453 | containerElement.removeChild(videoContainerElement); 454 | break; 455 | } 456 | } 457 | } 458 | 459 | this.on = function(eventName, listener) { 460 | if (destroyed) { 461 | throw new Error('Video is destroyed'); 462 | } 463 | 464 | events.on(eventName, listener); 465 | }; 466 | this.dispatch = function(action) { 467 | if (destroyed) { 468 | throw new Error('Video is destroyed'); 469 | } 470 | 471 | if (action) { 472 | action = deepFreeze(cloneDeep(action)); 473 | switch (action.type) { 474 | case 'observeProp': { 475 | observeProp(action.propName); 476 | return; 477 | } 478 | case 'setProp': { 479 | setProp(action.propName, action.propValue); 480 | return; 481 | } 482 | case 'command': { 483 | command(action.commandName, action.commandArgs); 484 | return; 485 | } 486 | } 487 | } 488 | 489 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 490 | }; 491 | } 492 | 493 | YouTubeVideo.canPlayStream = function(stream) { 494 | return Promise.resolve(stream && typeof stream.ytId === 'string'); 495 | }; 496 | 497 | YouTubeVideo.manifest = { 498 | name: 'YouTubeVideo', 499 | external: false, 500 | props: ['stream', 'loaded', 'paused', 'time', 'duration', 'buffering', 'volume', 'muted', 'playbackSpeed', 'subtitlesTracks', 'selectedSubtitlesTrackId'], 501 | commands: ['load', 'unload', 'destroy'], 502 | events: ['propValue', 'propChanged', 'ended', 'error', 'subtitlesTrackLoaded'] 503 | }; 504 | 505 | module.exports = YouTubeVideo; 506 | -------------------------------------------------------------------------------- /src/withStreamingServer/withStreamingServer.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var url = require('url'); 3 | var hat = require('hat'); 4 | var cloneDeep = require('lodash.clonedeep'); 5 | var deepFreeze = require('deep-freeze'); 6 | var mediaCapabilities = require('../mediaCapabilities'); 7 | var convertStream = require('./convertStream'); 8 | var fetchVideoParams = require('./fetchVideoParams'); 9 | var isPlayerLoaded = require('./isPlayerLoaded'); 10 | var supportsTranscoding = require('../supportsTranscoding'); 11 | var ERROR = require('../error'); 12 | 13 | function withStreamingServer(Video) { 14 | function VideoWithStreamingServer(options) { 15 | options = options || {}; 16 | 17 | var video = new Video(options); 18 | video.on('error', onVideoError); 19 | video.on('propValue', onVideoPropEvent.bind(null, 'propValue')); 20 | video.on('propChanged', onVideoPropEvent.bind(null, 'propChanged')); 21 | Video.manifest.events 22 | .filter(function(eventName) { 23 | return !['error', 'propValue', 'propChanged'].includes(eventName); 24 | }) 25 | .forEach(function(eventName) { 26 | video.on(eventName, onOtherVideoEvent(eventName)); 27 | }); 28 | 29 | var self = this; 30 | var loadArgs = null; 31 | var loaded = false; 32 | var actionsQueue = []; 33 | var videoParams = null; 34 | var events = new EventEmitter(); 35 | var destroyed = false; 36 | var observedProps = { 37 | stream: false, 38 | videoParams: false 39 | }; 40 | 41 | function flushActionsQueue() { 42 | while (actionsQueue.length > 0) { 43 | var action = actionsQueue.shift(); 44 | self.dispatch.call(self, action); 45 | } 46 | } 47 | function onVideoError(error) { 48 | events.emit('error', error); 49 | if (error.critical) { 50 | command('unload'); 51 | } 52 | } 53 | function onVideoPropEvent(eventName, propName, propValue) { 54 | events.emit(eventName, propName, getProp(propName, propValue)); 55 | } 56 | function onOtherVideoEvent(eventName) { 57 | return function() { 58 | events.emit.apply(events, [eventName].concat(Array.from(arguments))); 59 | }; 60 | } 61 | function onPropChanged(propName) { 62 | if (observedProps[propName]) { 63 | events.emit('propChanged', propName, getProp(propName, null)); 64 | } 65 | } 66 | function onError(error) { 67 | events.emit('error', error); 68 | if (error.critical) { 69 | command('unload'); 70 | video.dispatch({ type: 'command', commandName: 'unload' }); 71 | } 72 | } 73 | function getProp(propName, videoPropValue) { 74 | switch (propName) { 75 | case 'stream': { 76 | return loadArgs !== null ? loadArgs.stream : null; 77 | } 78 | case 'videoParams': { 79 | return videoParams; 80 | } 81 | default: { 82 | return videoPropValue; 83 | } 84 | } 85 | } 86 | function observeProp(propName) { 87 | switch (propName) { 88 | case 'stream': 89 | case 'videoParams': { 90 | events.emit('propValue', propName, getProp(propName, null)); 91 | observedProps[propName] = true; 92 | return true; 93 | } 94 | default: { 95 | return false; 96 | } 97 | } 98 | } 99 | function command(commandName, commandArgs) { 100 | switch (commandName) { 101 | case 'load': { 102 | if (commandArgs && commandArgs.stream && typeof commandArgs.streamingServerURL === 'string') { 103 | command('unload'); 104 | video.dispatch({ type: 'command', commandName: 'unload' }); 105 | loadArgs = commandArgs; 106 | onPropChanged('stream'); 107 | convertStream(commandArgs.streamingServerURL, commandArgs.stream, commandArgs.seriesInfo, commandArgs.streamingServerSettings) 108 | .then(function(result) { 109 | var mediaURL = result.url; 110 | var infoHash = result.infoHash; 111 | var fileIdx = result.fileIdx; 112 | var formats = Array.isArray(commandArgs.formats) ? 113 | commandArgs.formats 114 | : 115 | mediaCapabilities.formats; 116 | var videoCodecs = Array.isArray(commandArgs.videoCodecs) ? 117 | commandArgs.videoCodecs 118 | : 119 | mediaCapabilities.videoCodecs; 120 | var audioCodecs = Array.isArray(commandArgs.audioCodecs) ? 121 | commandArgs.audioCodecs 122 | : 123 | mediaCapabilities.audioCodecs; 124 | var maxAudioChannels = commandArgs.maxAudioChannels !== null && isFinite(commandArgs.maxAudioChannels) ? 125 | commandArgs.maxAudioChannels 126 | : 127 | mediaCapabilities.maxAudioChannels; 128 | var canPlayStreamOptions = Object.assign({}, commandArgs, { 129 | formats: formats, 130 | videoCodecs: videoCodecs, 131 | audioCodecs: audioCodecs, 132 | maxAudioChannels: maxAudioChannels 133 | }); 134 | return (commandArgs.forceTranscoding ? Promise.resolve(false) : VideoWithStreamingServer.canPlayStream({ url: mediaURL }, canPlayStreamOptions)) 135 | .catch(function(error) { 136 | console.warn('Media probe error', error); 137 | return false; 138 | }) 139 | .then(function(canPlay) { 140 | if (canPlay) { 141 | return { 142 | mediaURL: mediaURL, 143 | infoHash: infoHash, 144 | fileIdx: fileIdx, 145 | stream: { 146 | url: mediaURL 147 | } 148 | }; 149 | } 150 | 151 | var id = hat(); 152 | var queryParams = new URLSearchParams([['mediaURL', mediaURL]]); 153 | if (commandArgs.forceTranscoding) { 154 | queryParams.set('forceTranscoding', '1'); 155 | } 156 | 157 | videoCodecs.forEach(function(videoCodec) { 158 | queryParams.append('videoCodecs', videoCodec); 159 | }); 160 | 161 | audioCodecs.forEach(function(audioCodec) { 162 | queryParams.append('audioCodecs', audioCodec); 163 | }); 164 | 165 | queryParams.set('maxAudioChannels', maxAudioChannels); 166 | 167 | return { 168 | mediaURL: mediaURL, 169 | infoHash: infoHash, 170 | fileIdx: fileIdx, 171 | stream: { 172 | url: url.resolve(commandArgs.streamingServerURL, '/hlsv2/' + id + '/master.m3u8?' + queryParams.toString()), 173 | subtitles: Array.isArray(commandArgs.stream.subtitles) ? 174 | commandArgs.stream.subtitles.map(function(track) { 175 | return Object.assign({}, track, { 176 | url: typeof track.url === 'string' ? 177 | url.resolve(commandArgs.streamingServerURL, '/subtitles.vtt?' + new URLSearchParams([['from', track.url]]).toString()) 178 | : 179 | track.url 180 | }); 181 | }) 182 | : 183 | [], 184 | behaviorHints: { 185 | headers: { 186 | 'content-type': 'application/vnd.apple.mpegurl' 187 | } 188 | } 189 | } 190 | }; 191 | }); 192 | }) 193 | .then(function(result) { 194 | if (commandArgs !== loadArgs) { 195 | return; 196 | } 197 | 198 | video.dispatch({ 199 | type: 'command', 200 | commandName: 'load', 201 | commandArgs: Object.assign({}, commandArgs, { 202 | stream: result.stream 203 | }) 204 | }); 205 | loaded = true; 206 | flushActionsQueue(); 207 | 208 | isPlayerLoaded(video, Video.manifest.props) 209 | .then(function() { 210 | return fetchVideoParams(commandArgs.streamingServerURL, result.mediaURL, result.infoHash, result.fileIdx, commandArgs.stream.behaviorHints); 211 | }) 212 | .then(function(result) { 213 | if (commandArgs !== loadArgs) { 214 | return; 215 | } 216 | 217 | videoParams = result; 218 | onPropChanged('videoParams'); 219 | }) 220 | .catch(function(error) { 221 | if (commandArgs !== loadArgs) { 222 | return; 223 | } 224 | 225 | // eslint-disable-next-line no-console 226 | console.error(error); 227 | videoParams = { hash: null, size: null, filename: null }; 228 | onPropChanged('videoParams'); 229 | }); 230 | }) 231 | .catch(function(error) { 232 | if (commandArgs !== loadArgs) { 233 | return; 234 | } 235 | 236 | onError(Object.assign({}, ERROR.WITH_STREAMING_SERVER.CONVERT_FAILED, { 237 | error: error, 238 | critical: true, 239 | stream: commandArgs.stream, 240 | streamingServerURL: commandArgs.streamingServerURL 241 | })); 242 | }); 243 | } else { 244 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 245 | critical: true, 246 | stream: commandArgs ? commandArgs.stream : null, 247 | streamingServerURL: commandArgs && typeof commandArgs.streamingServerURL === 'string' ? commandArgs.streamingServerURL : null 248 | })); 249 | } 250 | 251 | return true; 252 | } 253 | case 'addExtraSubtitlesTracks': { 254 | if (loadArgs && commandArgs && Array.isArray(commandArgs.tracks)) { 255 | if (loaded) { 256 | video.dispatch({ 257 | type: 'command', 258 | commandName: 'addExtraSubtitlesTracks', 259 | commandArgs: Object.assign({}, commandArgs, { 260 | tracks: commandArgs.tracks.map(function(track) { 261 | return Object.assign({}, track, { 262 | // fallback is used in case server conversion fails (if server is offline) 263 | fallbackUrl: track.url, 264 | url: typeof track.url === 'string' ? 265 | url.resolve(loadArgs.streamingServerURL, '/subtitles.vtt?' + new URLSearchParams([['from', track.url]]).toString()) 266 | : 267 | track.url 268 | }); 269 | }) 270 | }) 271 | }); 272 | } else { 273 | actionsQueue.push({ 274 | type: 'command', 275 | commandName: 'addExtraSubtitlesTracks', 276 | commandArgs: commandArgs 277 | }); 278 | } 279 | } 280 | 281 | return true; 282 | } 283 | case 'unload': { 284 | loadArgs = null; 285 | loaded = false; 286 | actionsQueue = []; 287 | videoParams = null; 288 | onPropChanged('stream'); 289 | onPropChanged('videoParams'); 290 | return false; 291 | } 292 | case 'destroy': { 293 | command('unload'); 294 | destroyed = true; 295 | video.dispatch({ type: 'command', commandName: 'destroy' }); 296 | events.removeAllListeners(); 297 | return true; 298 | } 299 | default: { 300 | if (!loaded) { 301 | actionsQueue.push({ 302 | type: 'command', 303 | commandName: commandName, 304 | commandArgs: commandArgs 305 | }); 306 | 307 | return true; 308 | } 309 | 310 | return false; 311 | } 312 | } 313 | } 314 | 315 | this.on = function(eventName, listener) { 316 | if (destroyed) { 317 | throw new Error('Video is destroyed'); 318 | } 319 | 320 | events.on(eventName, listener); 321 | }; 322 | this.dispatch = function(action) { 323 | if (destroyed) { 324 | throw new Error('Video is destroyed'); 325 | } 326 | 327 | if (action) { 328 | action = deepFreeze(cloneDeep(action)); 329 | switch (action.type) { 330 | case 'observeProp': { 331 | if (observeProp(action.propName)) { 332 | return; 333 | } 334 | 335 | break; 336 | } 337 | case 'command': { 338 | if (command(action.commandName, action.commandArgs)) { 339 | return; 340 | } 341 | 342 | break; 343 | } 344 | } 345 | } 346 | 347 | video.dispatch(action); 348 | }; 349 | } 350 | 351 | VideoWithStreamingServer.canPlayStream = function(stream, options) { 352 | return supportsTranscoding() 353 | .then(function(supported) { 354 | if (!supported) { 355 | // we cannot probe the video in this case 356 | return Video.canPlayStream(stream); 357 | } 358 | // probing normally gives more accurate results 359 | var queryParams = new URLSearchParams([['mediaURL', stream.url]]); 360 | return fetch(url.resolve(options.streamingServerURL, '/hlsv2/probe?' + queryParams.toString())) 361 | .then(function(resp) { 362 | return resp.json(); 363 | }) 364 | .then(function(probe) { 365 | var isFormatSupported = options.formats.some(function(format) { 366 | return probe.format.name.indexOf(format) !== -1; 367 | }); 368 | var areStreamsSupported = probe.streams.every(function(stream) { 369 | if (stream.track === 'audio') { 370 | return stream.channels <= options.maxAudioChannels && 371 | options.audioCodecs.indexOf(stream.codec) !== -1; 372 | } else if (stream.track === 'video') { 373 | return options.videoCodecs.indexOf(stream.codec) !== -1; 374 | } 375 | 376 | return true; 377 | }); 378 | return isFormatSupported && areStreamsSupported; 379 | }) 380 | .catch(function() { 381 | // this uses content-type header in HTMLVideo which 382 | // is unreliable, check can also fail due to CORS 383 | return Video.canPlayStream(stream); 384 | }); 385 | }); 386 | }; 387 | 388 | VideoWithStreamingServer.manifest = { 389 | name: Video.manifest.name + 'WithStreamingServer', 390 | external: Video.manifest.external, 391 | props: Video.manifest.props.concat(['stream', 'videoParams']) 392 | .filter(function(value, index, array) { return array.indexOf(value) === index; }), 393 | commands: Video.manifest.commands.concat(['load', 'unload', 'destroy', 'addExtraSubtitlesTracks']) 394 | .filter(function(value, index, array) { return array.indexOf(value) === index; }), 395 | events: Video.manifest.events.concat(['propValue', 'propChanged', 'error']) 396 | .filter(function(value, index, array) { return array.indexOf(value) === index; }) 397 | }; 398 | 399 | return VideoWithStreamingServer; 400 | } 401 | 402 | module.exports = withStreamingServer; 403 | -------------------------------------------------------------------------------- /src/ShellVideo/ShellVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var ERROR = require('../error'); 5 | 6 | var SUBS_SCALE_FACTOR = 0.0066; 7 | 8 | var stremioToMPVProps = { 9 | 'loaded': 'loaded', 10 | 'stream': null, 11 | 'paused': 'pause', 12 | 'time': 'time-pos', 13 | 'duration': 'duration', 14 | 'buffering': 'buffering', 15 | 'volume': 'volume', 16 | 'muted': 'mute', 17 | 'playbackSpeed': 'speed', 18 | 'audioTracks': 'audioTracks', 19 | 'selectedAudioTrackId': 'aid', 20 | 'subtitlesTracks': 'subtitlesTracks', 21 | 'selectedSubtitlesTrackId': 'sid', 22 | 'subtitlesSize': 'sub-scale', 23 | 'subtitlesOffset': 'sub-pos', 24 | 'subtitlesDelay': 'sub-delay', 25 | 'subtitlesTextColor': 'sub-color', 26 | 'subtitlesBackgroundColor': 'sub-back-color', 27 | 'subtitlesOutlineColor': 'sub-border-color', 28 | }; 29 | 30 | function parseVersion(version) { 31 | return version.split('.').slice(0, 2).map(function (v) { return parseInt(v); }); 32 | } 33 | 34 | function versionGTE(a, b) { 35 | var versionA = parseVersion(a); 36 | var versionB = parseVersion(b); 37 | if (versionA[0] > versionB[0]) return true; 38 | if (versionA[0] < versionB[0]) return false; 39 | return versionA[1] >= versionB[1]; 40 | } 41 | 42 | function ShellVideo(options) { 43 | options = options || {}; 44 | 45 | var ipc = options.shellTransport; 46 | var observedProps = {}; 47 | var props = {}; 48 | var stremioProps = {}; 49 | Object.keys(stremioToMPVProps).forEach(function(key) { 50 | if(stremioToMPVProps[key]) { 51 | stremioProps[stremioToMPVProps[key]] = key; 52 | } 53 | }); 54 | var resolveMPVVersion; 55 | var waitForMPVVersion = new Promise(function (resolve) { 56 | resolveMPVVersion = resolve; 57 | }); 58 | command('unload'); 59 | 60 | ipc.send('mpv-command', ['stop']); 61 | ipc.send('mpv-observe-prop', 'path'); 62 | 63 | ipc.send('mpv-observe-prop', 'time-pos'); 64 | ipc.send('mpv-observe-prop', 'volume'); 65 | ipc.send('mpv-observe-prop', 'pause'); 66 | ipc.send('mpv-observe-prop', 'seeking'); 67 | ipc.send('mpv-observe-prop', 'eof-reached'); 68 | 69 | ipc.send('mpv-observe-prop', 'duration'); 70 | ipc.send('mpv-observe-prop', 'metadata'); 71 | ipc.send('mpv-observe-prop', 'video-params'); // video width/height 72 | ipc.send('mpv-observe-prop', 'track-list'); 73 | 74 | ipc.send('mpv-observe-prop', 'paused-for-cache'); 75 | ipc.send('mpv-observe-prop', 'cache-buffering-state'); 76 | 77 | ipc.send('mpv-observe-prop', 'aid'); 78 | ipc.send('mpv-observe-prop', 'vid'); 79 | ipc.send('mpv-observe-prop', 'sid'); 80 | ipc.send('mpv-observe-prop', 'sub-scale'); 81 | ipc.send('mpv-observe-prop', 'sub-pos'); 82 | ipc.send('mpv-observe-prop', 'sub-delay'); 83 | ipc.send('mpv-observe-prop', 'speed'); 84 | 85 | ipc.send('mpv-observe-prop', 'mpv-version'); 86 | ipc.send('mpv-observe-prop', 'ffmpeg-version'); 87 | 88 | var events = new EventEmitter(); 89 | var destroyed = false; 90 | var stream = null; 91 | 92 | var avgDuration = 0; 93 | var minClipDuration = 30; 94 | 95 | function setBackground(visible) { 96 | // This is a bit of a hack but there is no better way so far 97 | var bg = visible ? '' : 'transparent'; 98 | for(var container = options.containerElement; container; container = container.parentElement) { 99 | container.style.background = bg; 100 | } 101 | if (((window || {}).document || {}).getElementsByTagName) { 102 | var body = window.document.getElementsByTagName('body'); 103 | if ((body || [])[0]) { 104 | body[0].style.background = bg; 105 | } 106 | } 107 | } 108 | function logProp(args) { 109 | // eslint-disable-next-line no-console 110 | console.log(args.name+': '+args.data); 111 | } 112 | function embeddedProp(args) { 113 | return args.data && args.data !== 'no' ? 'EMBEDDED_' + args.data.toString() : null; 114 | } 115 | 116 | var last_time = 0; 117 | ipc.on('mpv-prop-change', function(args) { 118 | switch (args.name) { 119 | case 'mpv-version': 120 | resolveMPVVersion(args.data); 121 | props[args.name] = logProp(args); 122 | break; 123 | case 'ffmpeg-version': { 124 | props[args.name] = logProp(args); 125 | break; 126 | } 127 | case 'duration': { 128 | var intDuration = args.data | 0; 129 | // Accumulate average duration over time. if it is greater than minClipDuration 130 | // and equal to the currently reported duration, it is returned as video length. 131 | // If the reported duration changes over time the average duration is always 132 | // smaller than the currently reported one so we set the video length to 0 as 133 | // this is a live stream. 134 | props[args.name] = args.data >= minClipDuration && (!avgDuration || intDuration === avgDuration) ? Math.round(args.data * 1000) : null; 135 | // The average duration is calculated using right bit shifting by one of the sum of 136 | // the previous average and the currently reported value. This method is not very precise 137 | // as we get integer value but we avoid floating point errors. JS uses 32 bit values 138 | // for bitwise maths so the maximum supported video duration is 1073741823 (2 ^ 30 - 1) 139 | // which is around 34 years of playback time. 140 | avgDuration = avgDuration ? (avgDuration + intDuration) >> 1 : intDuration; 141 | props.loaded = intDuration > 0; 142 | if(props.loaded) { 143 | setBackground(false); 144 | onPropChanged('loaded'); 145 | } 146 | break; 147 | } 148 | case 'time-pos': { 149 | props[args.name] = Math.round(args.data*1000); 150 | break; 151 | } 152 | case 'sub-scale': { 153 | props[args.name] = Math.round(args.data / SUBS_SCALE_FACTOR); 154 | break; 155 | } 156 | case 'sub-pos': { 157 | props[args.name] = 100 - args.data; 158 | break; 159 | } 160 | case 'sub-delay': { 161 | props[args.name] = Math.round(args.data*1000); 162 | break; 163 | } 164 | case 'volume': { 165 | if (typeof args.data === 'number' && isFinite(args.data)) { 166 | props[args.name] = args.data; 167 | onPropChanged('volume'); 168 | } 169 | break; 170 | } 171 | case 'paused-for-cache': 172 | case 'seeking': 173 | { 174 | if(props.buffering !== args.data) { 175 | props.buffering = args.data; 176 | onPropChanged('buffering'); 177 | } 178 | break; 179 | } 180 | case 'aid': 181 | case 'sid': 182 | case 'vid': { 183 | props[args.name] = embeddedProp(args); 184 | break; 185 | } 186 | // In that case onPropChanged() is manually invoked as track-list contains all 187 | // the tracks but we have different event for each track type 188 | case 'track-list': { 189 | props.audioTracks = args.data.filter(function(x) { return x.type === 'audio'; }) 190 | .map(function(x, index) { 191 | return { 192 | id: 'EMBEDDED_' + x.id, 193 | lang: x.lang === undefined ? 'Track' + (index + 1) : x.lang, 194 | label: x.title === undefined || x.lang === undefined ? '' : x.title || x.lang, 195 | origin: 'EMBEDDED', 196 | embedded: true, 197 | mode: x.id === props.aid ? 'showing' : 'disabled', 198 | }; 199 | }); 200 | onPropChanged('audioTracks'); 201 | 202 | props.subtitlesTracks = args.data 203 | .filter(function(x) { return x.type === 'sub'; }) 204 | .map(function(x, index) { 205 | return { 206 | id: 'EMBEDDED_' + x.id, 207 | lang: x.lang === undefined ? 'Track ' + (index + 1) : x.lang, 208 | label: x.title === undefined || x.lang === undefined ? '' : x.title || x.lang, 209 | origin: 'EMBEDDED', 210 | embedded: true, 211 | mode: x.id === props.sid ? 'showing' : 'disabled', 212 | }; 213 | }); 214 | onPropChanged('subtitlesTracks'); 215 | break; 216 | } 217 | default: { 218 | props[args.name] = args.data; 219 | break; 220 | } 221 | } 222 | 223 | // Cap time update to update only when a second passes 224 | var current_time = args.name === 'time-pos' ? Math.floor(props['time-pos'] / 1000) : null; 225 | if((!current_time || last_time !== current_time)&& stremioProps[args.name]) { 226 | if(current_time) { 227 | last_time = current_time; 228 | } 229 | onPropChanged(stremioProps[args.name]); 230 | } 231 | }); 232 | ipc.on('mpv-event-ended', function(args) { 233 | if (args.error) onError(args.error); 234 | else onEnded(); 235 | }); 236 | 237 | function getProp(propName) { 238 | if(stremioToMPVProps[propName]) return props[stremioToMPVProps[propName]]; 239 | // eslint-disable-next-line no-console 240 | console.log('Unsupported prop requested', propName); 241 | return null; 242 | } 243 | function onError(error) { 244 | events.emit('error', error); 245 | if (error.critical) { 246 | command('unload'); 247 | } 248 | } 249 | function onEnded() { 250 | events.emit('ended'); 251 | } 252 | function onPropChanged(propName) { 253 | if (observedProps[propName]) { 254 | events.emit('propChanged', propName, getProp(propName)); 255 | } 256 | } 257 | function observeProp(propName) { 258 | events.emit('propValue', propName, getProp(propName)); 259 | observedProps[propName] = true; 260 | } 261 | function setProp(propName, propValue) { 262 | switch (propName) { 263 | case 'paused': { 264 | if (stream !== null) { 265 | ipc.send('mpv-set-prop', ['pause', propValue]); 266 | } 267 | 268 | break; 269 | } 270 | case 'time': { 271 | if (stream !== null && propValue !== null && isFinite(propValue)) { 272 | ipc.send('mpv-set-prop', ['time-pos', propValue/1000]); 273 | } 274 | 275 | break; 276 | } 277 | case 'playbackSpeed': { 278 | if (stream !== null && propValue !== null && isFinite(propValue)) { 279 | ipc.send('mpv-set-prop', ['speed', propValue]); 280 | } 281 | break; 282 | } 283 | case 'volume': { 284 | if (stream !== null && propValue !== null && isFinite(propValue)) { 285 | props.mute = false; 286 | ipc.send('mpv-set-prop', ['mute', 'no']); 287 | ipc.send('mpv-set-prop', ['volume', propValue]); 288 | onPropChanged('muted'); 289 | onPropChanged('volume'); 290 | } 291 | break; 292 | } 293 | case 'muted': { 294 | if (stream !== null) { 295 | ipc.send('mpv-set-prop', ['mute', propValue ? 'yes' : 'no']); 296 | props.mute = propValue; 297 | onPropChanged('muted'); 298 | } 299 | break; 300 | } 301 | case 'selectedAudioTrackId': { 302 | if (stream !== null) { 303 | var actualId = propValue.slice('EMBEDDED_'.length); 304 | ipc.send('mpv-set-prop', ['aid', actualId]); 305 | } 306 | break; 307 | } 308 | case 'selectedSubtitlesTrackId': { 309 | if (stream !== null) { 310 | if(propValue) { 311 | var actualId = propValue.slice('EMBEDDED_'.length); 312 | ipc.send('mpv-set-prop', ['sid', actualId]); 313 | events.emit('subtitlesTrackLoaded', propValue); 314 | } else { 315 | // turn off subs 316 | ipc.send('mpv-set-prop', ['sid', 'no']); 317 | props.sid = null; 318 | } 319 | } 320 | onPropChanged('selectedSubtitlesTrackId'); 321 | break; 322 | } 323 | case 'subtitlesSize': { 324 | ipc.send('mpv-set-prop', [stremioToMPVProps[propName], propValue * SUBS_SCALE_FACTOR]); 325 | break; 326 | } 327 | case 'subtitlesDelay': { 328 | ipc.send('mpv-set-prop', [stremioToMPVProps[propName], propValue]); 329 | break; 330 | } 331 | case 'subtitlesOffset': { 332 | ipc.send('mpv-set-prop', [stremioToMPVProps[propName], 100 - propValue]); 333 | break; 334 | } 335 | case 'subtitlesTextColor': 336 | case 'subtitlesBackgroundColor': 337 | case 'subtitlesOutlineColor': 338 | { 339 | // MPV accepts color in #AARRGGBB 340 | var argb = propValue.replace(/^#(\w{6})(\w{2})$/, '#$2$1'); 341 | ipc.send('mpv-set-prop', [stremioToMPVProps[propName], argb]); 342 | break; 343 | } 344 | default: { 345 | // eslint-disable-next-line no-console 346 | console.log('Unhandled setProp for', propName); 347 | } 348 | } 349 | } 350 | function command(commandName, commandArgs) { 351 | switch (commandName) { 352 | case 'load': { 353 | command('unload'); 354 | if (commandArgs && commandArgs.stream && typeof commandArgs.stream.url === 'string') { 355 | waitForMPVVersion.then(function (mpvVersion) { 356 | stream = commandArgs.stream; 357 | onPropChanged('stream'); 358 | 359 | var subAssOverride = commandArgs.assSubtitlesStyling ? 'strip' : 'no'; 360 | ipc.send('mpv-set-prop', ['sub-ass-override', subAssOverride]); 361 | 362 | // Hardware decoding 363 | var hwdecValue = commandArgs.hardwareDecoding ? 'auto-copy' : 'no'; 364 | ipc.send('mpv-set-prop', ['hwdec', hwdecValue]); 365 | 366 | // Video mode 367 | var videoOutput = commandArgs.platform === 'windows' ? (commandArgs.videoMode === null ? 'gpu-next' : 'gpu') : 'libmpv'; 368 | ipc.send('mpv-set-prop', ['vo', videoOutput]); 369 | 370 | var separateWindow = options.mpvSeparateWindow ? 'yes' : 'no'; 371 | ipc.send('mpv-set-prop', ['osc', separateWindow]); 372 | ipc.send('mpv-set-prop', ['input-default-bindings', separateWindow]); 373 | ipc.send('mpv-set-prop', ['input-vo-keyboard', separateWindow]); 374 | 375 | var startAt = Math.floor(parseInt(commandArgs.time, 10) / 1000) || 0; 376 | if (startAt !== 0) { 377 | if (versionGTE(mpvVersion, '0.39')) { 378 | ipc.send('mpv-command', ['loadfile', stream.url, 'replace', '-1', 'start=+' + startAt]); 379 | } else { 380 | ipc.send('mpv-command', ['loadfile', stream.url, 'replace', 'start=+' + startAt]); 381 | } 382 | } else { 383 | ipc.send('mpv-command', ['loadfile', stream.url]); 384 | } 385 | ipc.send('mpv-set-prop', ['pause', false]); 386 | ipc.send('mpv-set-prop', ['speed', props.speed]); 387 | if (props.aid) { 388 | if (typeof props.aid === 'string' && props.aid.startsWith('EMBEDDED_')) { 389 | ipc.send('mpv-set-prop', ['aid', props.aid.slice('EMBEDDED_'.length)]); 390 | } else { 391 | ipc.send('mpv-set-prop', ['aid', props.aid]); 392 | } 393 | } 394 | ipc.send('mpv-set-prop', ['mute', 'no']); 395 | 396 | onPropChanged('paused'); 397 | onPropChanged('time'); 398 | onPropChanged('duration'); 399 | onPropChanged('buffering'); 400 | onPropChanged('muted'); 401 | onPropChanged('subtitlesTracks'); 402 | onPropChanged('selectedSubtitlesTrackId'); 403 | }); 404 | } else { 405 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 406 | critical: true, 407 | stream: commandArgs ? commandArgs.stream : null 408 | })); 409 | } 410 | break; 411 | } 412 | case 'unload': { 413 | props = { 414 | loaded: false, 415 | pause: false, 416 | mute: false, 417 | speed: 1, 418 | subtitlesTracks: [], 419 | audioTracks: [], 420 | buffering: false, 421 | aid: null, 422 | sid: null, 423 | }; 424 | avgDuration = 0; 425 | ipc.send('mpv-command', ['stop']); 426 | onPropChanged('loaded'); 427 | onPropChanged('stream'); 428 | onPropChanged('paused'); 429 | onPropChanged('time'); 430 | onPropChanged('duration'); 431 | onPropChanged('buffering'); 432 | onPropChanged('muted'); 433 | onPropChanged('subtitlesTracks'); 434 | onPropChanged('selectedSubtitlesTrackId'); 435 | setBackground(true); 436 | break; 437 | } 438 | case 'destroy': { 439 | command('unload'); 440 | destroyed = true; 441 | events.removeAllListeners(); 442 | break; 443 | } 444 | } 445 | } 446 | 447 | this.on = function (eventName, listener) { 448 | if (destroyed) { 449 | throw new Error('Video is destroyed'); 450 | } 451 | 452 | events.on(eventName, listener); 453 | }; 454 | this.dispatch = function (action) { 455 | if (destroyed) { 456 | throw new Error('Video is destroyed'); 457 | } 458 | 459 | if (action) { 460 | action = deepFreeze(cloneDeep(action)); 461 | switch (action.type) { 462 | case 'observeProp': { 463 | observeProp(action.propName); 464 | break; 465 | } 466 | case 'setProp': { 467 | setProp(action.propName, action.propValue); 468 | return; 469 | } 470 | case 'command': { 471 | command( 472 | action.commandName, 473 | action.commandArgs 474 | ); 475 | return; 476 | } 477 | } 478 | } 479 | }; 480 | } 481 | ShellVideo.canPlayStream = function() { 482 | return Promise.resolve(true); 483 | }; 484 | 485 | ShellVideo.manifest = { 486 | name: 'ShellVideo', 487 | external: false, 488 | props: Object.keys(stremioToMPVProps), 489 | commands: ['load', 'unload', 'destroy'], 490 | events: [ 491 | 'propValue', 492 | 'propChanged', 493 | 'ended', 494 | 'error', 495 | 'subtitlesTrackLoaded', 496 | ], 497 | }; 498 | 499 | module.exports = ShellVideo; 500 | -------------------------------------------------------------------------------- /src/VidaaVideo/VidaaVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var ERROR = require('../error'); 5 | 6 | var SSA_DESCRIPTORS_REGEX = /^\{(\\an[1-8])+\}/i; 7 | 8 | function VidaaVideo(options) { 9 | options = options || {}; 10 | 11 | var containerElement = options.containerElement; 12 | if (!(containerElement instanceof HTMLElement)) { 13 | throw new Error('Container element required to be instance of HTMLElement'); 14 | } 15 | 16 | var videoElement = document.createElement('video'); 17 | videoElement.style.width = '100%'; 18 | videoElement.style.height = '100%'; 19 | videoElement.style.backgroundColor = 'black'; 20 | videoElement.controls = false; 21 | videoElement.playsInline = true; 22 | videoElement.onerror = function() { 23 | onVideoError(); 24 | }; 25 | videoElement.onended = function() { 26 | onEnded(); 27 | }; 28 | videoElement.onpause = function() { 29 | onPropChanged('paused'); 30 | }; 31 | videoElement.onplay = function() { 32 | onPropChanged('paused'); 33 | }; 34 | videoElement.ontimeupdate = function() { 35 | onPropChanged('time'); 36 | }; 37 | videoElement.ondurationchange = function() { 38 | onPropChanged('duration'); 39 | }; 40 | videoElement.onwaiting = function() { 41 | onPropChanged('buffering'); 42 | }; 43 | videoElement.onseeking = function() { 44 | onPropChanged('time'); 45 | onPropChanged('buffering'); 46 | }; 47 | videoElement.onseeked = function() { 48 | onPropChanged('time'); 49 | onPropChanged('buffering'); 50 | }; 51 | videoElement.onstalled = function() { 52 | onPropChanged('buffering'); 53 | }; 54 | videoElement.onplaying = function() { 55 | onPropChanged('time'); 56 | onPropChanged('buffering'); 57 | }; 58 | videoElement.oncanplay = function() { 59 | onPropChanged('buffering'); 60 | }; 61 | videoElement.canplaythrough = function() { 62 | onPropChanged('buffering'); 63 | }; 64 | videoElement.onloadedmetadata = function() { 65 | onPropChanged('loaded'); 66 | }; 67 | videoElement.onloadeddata = function() { 68 | onPropChanged('buffering'); 69 | }; 70 | videoElement.onvolumechange = function() { 71 | onPropChanged('volume'); 72 | onPropChanged('muted'); 73 | }; 74 | videoElement.onratechange = function() { 75 | onPropChanged('playbackSpeed'); 76 | }; 77 | videoElement.textTracks.onchange = function() { 78 | onPropChanged('subtitlesTracks'); 79 | onPropChanged('selectedSubtitlesTrackId'); 80 | onCueChange(); 81 | Array.from(videoElement.textTracks).forEach(function(track) { 82 | track.oncuechange = onCueChange; 83 | }); 84 | }; 85 | containerElement.appendChild(videoElement); 86 | 87 | var subtitlesElement = document.createElement('div'); 88 | subtitlesElement.style.position = 'absolute'; 89 | subtitlesElement.style.right = '0'; 90 | subtitlesElement.style.bottom = '0'; 91 | subtitlesElement.style.left = '0'; 92 | subtitlesElement.style.zIndex = '1'; 93 | subtitlesElement.style.textAlign = 'center'; 94 | containerElement.style.position = 'relative'; 95 | containerElement.style.zIndex = '0'; 96 | containerElement.appendChild(subtitlesElement); 97 | 98 | var events = new EventEmitter(); 99 | var destroyed = false; 100 | var stream = null; 101 | var observedProps = { 102 | stream: false, 103 | loaded: false, 104 | paused: false, 105 | time: false, 106 | duration: false, 107 | buffering: false, 108 | subtitlesTracks: false, 109 | selectedSubtitlesTrackId: false, 110 | audioTracks: false, 111 | selectedAudioTrackId: false, 112 | volume: false, 113 | muted: false, 114 | playbackSpeed: false 115 | }; 116 | 117 | function getProp(propName) { 118 | switch (propName) { 119 | case 'stream': { 120 | return stream; 121 | } 122 | case 'loaded': { 123 | if (stream === null) { 124 | return null; 125 | } 126 | 127 | return videoElement.readyState >= videoElement.HAVE_METADATA; 128 | } 129 | case 'paused': { 130 | if (stream === null) { 131 | return null; 132 | } 133 | 134 | return !!videoElement.paused; 135 | } 136 | case 'time': { 137 | if (stream === null || videoElement.currentTime === null || !isFinite(videoElement.currentTime)) { 138 | return null; 139 | } 140 | 141 | return Math.floor(videoElement.currentTime * 1000); 142 | } 143 | case 'duration': { 144 | if (stream === null || videoElement.duration === null || !isFinite(videoElement.duration)) { 145 | return null; 146 | } 147 | 148 | return Math.floor(videoElement.duration * 1000); 149 | } 150 | case 'buffering': { 151 | if (stream === null) { 152 | return null; 153 | } 154 | 155 | return videoElement.readyState < videoElement.HAVE_FUTURE_DATA; 156 | } 157 | case 'subtitlesTracks': { 158 | if (stream === null) { 159 | return []; 160 | } 161 | 162 | return Array.from(videoElement.textTracks) 163 | .map(function(track, index) { 164 | return Object.freeze({ 165 | id: 'EMBEDDED_' + String(index), 166 | lang: track.language, 167 | label: track.label || null, 168 | origin: 'EMBEDDED', 169 | embedded: true 170 | }); 171 | }); 172 | } 173 | case 'selectedSubtitlesTrackId': { 174 | if (stream === null) { 175 | return null; 176 | } 177 | 178 | return Array.from(videoElement.textTracks) 179 | .reduce(function(result, track, index) { 180 | if (result === null && track.mode === 'showing') { 181 | return 'EMBEDDED_' + String(index); 182 | } 183 | 184 | return result; 185 | }, null); 186 | } 187 | case 'audioTracks': { 188 | if (stream === null) { 189 | return []; 190 | } 191 | 192 | if (!videoElement.audioTracks || !Array.from(videoElement.audioTracks).length) { 193 | return []; 194 | } 195 | 196 | return Array.from(videoElement.audioTracks) 197 | .map(function(track, index) { 198 | return Object.freeze({ 199 | id: 'EMBEDDED_' + String(index), 200 | lang: track.language, 201 | label: track.label || null, 202 | origin: 'EMBEDDED', 203 | embedded: true 204 | }); 205 | }); 206 | } 207 | case 'selectedAudioTrackId': { 208 | 209 | if (stream === null) { 210 | return null; 211 | } 212 | 213 | if (!videoElement.audioTracks || !Array.from(videoElement.audioTracks).length) { 214 | return null; 215 | } 216 | 217 | return Array.from(videoElement.audioTracks) 218 | .reduce(function(result, track, index) { 219 | if (result === null && track.enabled) { 220 | return 'EMBEDDED_' + String(index); 221 | } 222 | 223 | return result; 224 | }, null); 225 | } 226 | case 'volume': { 227 | if (destroyed || videoElement.volume === null || !isFinite(videoElement.volume)) { 228 | return null; 229 | } 230 | 231 | return Math.floor(videoElement.volume * 100); 232 | } 233 | case 'muted': { 234 | if (destroyed) { 235 | return null; 236 | } 237 | 238 | return !!videoElement.muted; 239 | } 240 | case 'playbackSpeed': { 241 | if (destroyed || videoElement.playbackRate === null || !isFinite(videoElement.playbackRate)) { 242 | return null; 243 | } 244 | 245 | return videoElement.playbackRate; 246 | } 247 | default: { 248 | return null; 249 | } 250 | } 251 | } 252 | function onCueChange() { 253 | Array.from(videoElement.textTracks).forEach(function(track) { 254 | Array.from(track.cues || []).forEach(function(cue) { 255 | cue.snapToLines = false; 256 | cue.line = 100; 257 | }); 258 | }); 259 | } 260 | function onVideoError() { 261 | if (destroyed) { 262 | return; 263 | } 264 | 265 | var error; 266 | switch (videoElement.error.code) { 267 | case 1: { 268 | error = ERROR.HTML_VIDEO.MEDIA_ERR_ABORTED; 269 | break; 270 | } 271 | case 2: { 272 | error = ERROR.HTML_VIDEO.MEDIA_ERR_NETWORK; 273 | break; 274 | } 275 | case 3: { 276 | error = ERROR.HTML_VIDEO.MEDIA_ERR_DECODE; 277 | break; 278 | } 279 | case 4: { 280 | error = ERROR.HTML_VIDEO.MEDIA_ERR_SRC_NOT_SUPPORTED; 281 | break; 282 | } 283 | default: { 284 | error = ERROR.UNKNOWN_ERROR; 285 | } 286 | } 287 | onError(Object.assign({}, error, { 288 | critical: true, 289 | error: videoElement.error 290 | })); 291 | } 292 | function onError(error) { 293 | events.emit('error', error); 294 | if (error.critical) { 295 | command('unload'); 296 | } 297 | } 298 | function onEnded() { 299 | events.emit('ended'); 300 | } 301 | function onPropChanged(propName) { 302 | if (observedProps[propName]) { 303 | events.emit('propChanged', propName, getProp(propName)); 304 | } 305 | } 306 | function observeProp(propName) { 307 | if (observedProps.hasOwnProperty(propName)) { 308 | events.emit('propValue', propName, getProp(propName)); 309 | observedProps[propName] = true; 310 | } 311 | } 312 | function setProp(propName, propValue) { 313 | switch (propName) { 314 | case 'paused': { 315 | if (stream !== null) { 316 | propValue ? videoElement.pause() : videoElement.play(); 317 | onPropChanged('paused'); 318 | } 319 | 320 | break; 321 | } 322 | case 'time': { 323 | if (stream !== null && propValue !== null && isFinite(propValue)) { 324 | videoElement.currentTime = parseInt(propValue, 10) / 1000; 325 | onPropChanged('time'); 326 | } 327 | 328 | break; 329 | } 330 | case 'selectedSubtitlesTrackId': { 331 | if (stream !== null) { 332 | // important: disable all first 333 | Array.from(videoElement.textTracks) 334 | .forEach(function(track) { 335 | track.mode = 'disabled'; 336 | }); 337 | // then enable selected 338 | Array.from(videoElement.textTracks) 339 | .forEach(function(track, index) { 340 | if ('EMBEDDED_' + String(index) === propValue) { 341 | track.mode = 'showing'; 342 | } 343 | }); 344 | var selecterdSubtitlesTrack = getProp('subtitlesTracks') 345 | .find(function(track) { 346 | return track.id === propValue; 347 | }); 348 | if (selecterdSubtitlesTrack) { 349 | onPropChanged('selectedSubtitlesTrackId'); 350 | events.emit('subtitlesTrackLoaded', selecterdSubtitlesTrack); 351 | } 352 | } 353 | 354 | break; 355 | } 356 | case 'selectedAudioTrackId': { 357 | if (stream !== null) { 358 | for (var index = 0; index < videoElement.audioTracks.length; index++) { 359 | videoElement.audioTracks[index].enabled = !!('EMBEDDED_' + String(index) === propValue); 360 | } 361 | } 362 | 363 | var selectedAudioTrack = getProp('audioTracks') 364 | .find(function(track) { 365 | return track.id === propValue; 366 | }); 367 | 368 | if (selectedAudioTrack) { 369 | onPropChanged('selectedAudioTrackId'); 370 | events.emit('audioTrackLoaded', selectedAudioTrack); 371 | } 372 | 373 | break; 374 | } 375 | case 'volume': { 376 | if (propValue !== null && isFinite(propValue)) { 377 | videoElement.muted = false; 378 | videoElement.volume = Math.max(0, Math.min(100, parseInt(propValue, 10))) / 100; 379 | onPropChanged('muted'); 380 | onPropChanged('volume'); 381 | } 382 | 383 | break; 384 | } 385 | case 'muted': { 386 | videoElement.muted = !!propValue; 387 | onPropChanged('muted'); 388 | break; 389 | } 390 | case 'playbackSpeed': { 391 | if (propValue !== null && isFinite(propValue)) { 392 | videoElement.playbackRate = parseFloat(propValue); 393 | onPropChanged('playbackSpeed'); 394 | } 395 | 396 | break; 397 | } 398 | } 399 | } 400 | function command(commandName, commandArgs) { 401 | switch (commandName) { 402 | case 'load': { 403 | command('unload'); 404 | if (commandArgs && commandArgs.stream && typeof commandArgs.stream.url === 'string') { 405 | stream = commandArgs.stream; 406 | onPropChanged('stream'); 407 | onPropChanged('loaded'); 408 | videoElement.autoplay = typeof commandArgs.autoplay === 'boolean' ? commandArgs.autoplay : true; 409 | videoElement.currentTime = commandArgs.time !== null && isFinite(commandArgs.time) ? parseInt(commandArgs.time, 10) / 1000 : 0; 410 | onPropChanged('paused'); 411 | onPropChanged('time'); 412 | onPropChanged('duration'); 413 | onPropChanged('buffering'); 414 | if (videoElement.textTracks) { 415 | videoElement.textTracks.onaddtrack = function() { 416 | setTimeout(function() { 417 | // starts with embedded track selected 418 | // we'll first select some embedded track 419 | Array.from(videoElement.textTracks) 420 | .forEach(function(track, index) { 421 | if (!index) { 422 | track.mode = 'showing'; 423 | } 424 | }); 425 | setTimeout(function() { 426 | // then disable all embedded tracks on start 427 | Array.from(videoElement.textTracks) 428 | .forEach(function(track) { 429 | track.mode = 'disabled'; 430 | }); 431 | setTimeout(function() { 432 | onPropChanged('subtitlesTracks'); 433 | onPropChanged('selectedSubtitlesTrackId'); 434 | }); 435 | }); 436 | }); 437 | }; 438 | } 439 | if (videoElement.audioTracks) { 440 | videoElement.audioTracks.onaddtrack = function() { 441 | setTimeout(function() { 442 | onPropChanged('audioTracks'); 443 | onPropChanged('selectedAudioTrackId'); 444 | }); 445 | }; 446 | } 447 | videoElement.src = stream.url; 448 | } else { 449 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 450 | critical: true, 451 | stream: commandArgs ? commandArgs.stream : null 452 | })); 453 | } 454 | break; 455 | } 456 | case 'unload': { 457 | videoElement.textTracks.onaddtrack = null; 458 | videoElement.audioTracks.onaddtrack = null; 459 | stream = null; 460 | Array.from(videoElement.textTracks).forEach(function(track) { 461 | track.oncuechange = null; 462 | }); 463 | videoElement.removeAttribute('src'); 464 | videoElement.load(); 465 | videoElement.currentTime = 0; 466 | onPropChanged('stream'); 467 | onPropChanged('loaded'); 468 | onPropChanged('paused'); 469 | onPropChanged('time'); 470 | onPropChanged('duration'); 471 | onPropChanged('buffering'); 472 | onPropChanged('subtitlesTracks'); 473 | onPropChanged('selectedSubtitlesTrackId'); 474 | onPropChanged('audioTracks'); 475 | onPropChanged('selectedAudioTrackId'); 476 | break; 477 | } 478 | case 'destroy': { 479 | command('unload'); 480 | destroyed = true; 481 | onPropChanged('volume'); 482 | onPropChanged('muted'); 483 | onPropChanged('playbackSpeed'); 484 | events.removeAllListeners(); 485 | videoElement.onerror = null; 486 | videoElement.onended = null; 487 | videoElement.onpause = null; 488 | videoElement.onplay = null; 489 | videoElement.ontimeupdate = null; 490 | videoElement.ondurationchange = null; 491 | videoElement.onwaiting = null; 492 | videoElement.onseeking = null; 493 | videoElement.onseeked = null; 494 | videoElement.onstalled = null; 495 | videoElement.onplaying = null; 496 | videoElement.oncanplay = null; 497 | videoElement.canplaythrough = null; 498 | videoElement.onloadeddata = null; 499 | videoElement.onvolumechange = null; 500 | videoElement.onratechange = null; 501 | videoElement.textTracks.onchange = null; 502 | containerElement.removeChild(videoElement); 503 | break; 504 | } 505 | } 506 | } 507 | 508 | this.on = function(eventName, listener) { 509 | if (destroyed) { 510 | throw new Error('Video is destroyed'); 511 | } 512 | 513 | events.on(eventName, listener); 514 | }; 515 | this.dispatch = function(action) { 516 | if (destroyed) { 517 | throw new Error('Video is destroyed'); 518 | } 519 | 520 | if (action) { 521 | action = deepFreeze(cloneDeep(action)); 522 | switch (action.type) { 523 | case 'observeProp': { 524 | observeProp(action.propName); 525 | return; 526 | } 527 | case 'setProp': { 528 | setProp(action.propName, action.propValue); 529 | return; 530 | } 531 | case 'command': { 532 | command(action.commandName, action.commandArgs); 533 | return; 534 | } 535 | } 536 | } 537 | 538 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 539 | }; 540 | } 541 | 542 | VidaaVideo.canPlayStream = function(stream) { 543 | if (!stream) { 544 | return Promise.resolve(false); 545 | } 546 | 547 | return Promise.resolve(true); 548 | }; 549 | 550 | VidaaVideo.manifest = { 551 | name: 'VidaaVideo', 552 | external: false, 553 | props: ['stream', 'loaded', 'paused', 'time', 'duration', 'buffering', 'audioTracks', 'selectedAudioTrackId', 'subtitlesTracks', 'selectedSubtitlesTrackId', 'volume', 'muted', 'playbackSpeed'], 554 | commands: ['load', 'unload', 'destroy'], 555 | events: ['propValue', 'propChanged', 'ended', 'error', 'subtitlesTrackLoaded', 'audioTrackLoaded'] 556 | }; 557 | 558 | module.exports = VidaaVideo; 559 | -------------------------------------------------------------------------------- /src/withHTMLSubtitles/withHTMLSubtitles.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var Color = require('color'); 5 | var ERROR = require('../error'); 6 | var subtitlesParser = require('./subtitlesParser'); 7 | var subtitlesRenderer = require('./subtitlesRenderer'); 8 | var subtitlesConverter = require('./subtitlesConverter'); 9 | 10 | function withHTMLSubtitles(Video) { 11 | function VideoWithHTMLSubtitles(options) { 12 | options = options || {}; 13 | 14 | var video = new Video(options); 15 | video.on('error', onVideoError); 16 | video.on('propValue', onVideoPropEvent.bind(null, 'propValue')); 17 | video.on('propChanged', onVideoPropEvent.bind(null, 'propChanged')); 18 | Video.manifest.events 19 | .filter(function(eventName) { 20 | return !['error', 'propValue', 'propChanged'].includes(eventName); 21 | }) 22 | .forEach(function(eventName) { 23 | video.on(eventName, onOtherVideoEvent(eventName)); 24 | }); 25 | 26 | var containerElement = options.containerElement; 27 | if (!(containerElement instanceof HTMLElement)) { 28 | throw new Error('Container element required to be instance of HTMLElement'); 29 | } 30 | 31 | var subtitlesElement = document.createElement('div'); 32 | subtitlesElement.style.position = 'absolute'; 33 | subtitlesElement.style.right = '0'; 34 | subtitlesElement.style.bottom = '0'; 35 | subtitlesElement.style.left = '0'; 36 | subtitlesElement.style.zIndex = '1'; 37 | subtitlesElement.style.textAlign = 'center'; 38 | containerElement.style.position = 'relative'; 39 | containerElement.style.zIndex = '0'; 40 | containerElement.appendChild(subtitlesElement); 41 | 42 | var videoState = { 43 | time: null 44 | }; 45 | var cuesByTime = null; 46 | var events = new EventEmitter(); 47 | var destroyed = false; 48 | var tracks = []; 49 | var selectedTrackId = null; 50 | var delay = null; 51 | var size = 100; 52 | var offset = 0; 53 | var textColor = 'rgb(255, 255, 255)'; 54 | var backgroundColor = 'rgba(0, 0, 0, 0)'; 55 | var outlineColor = 'rgb(34, 34, 34)'; 56 | var opacity = 1; 57 | 58 | var observedProps = { 59 | extraSubtitlesTracks: false, 60 | selectedExtraSubtitlesTrackId: false, 61 | extraSubtitlesDelay: false, 62 | extraSubtitlesSize: false, 63 | extraSubtitlesOffset: false, 64 | extraSubtitlesTextColor: false, 65 | extraSubtitlesBackgroundColor: false, 66 | extraSubtitlesOutlineColor: false, 67 | extraSubtitlesOpacity: false 68 | }; 69 | 70 | function renderSubtitles() { 71 | while (subtitlesElement.hasChildNodes()) { 72 | subtitlesElement.removeChild(subtitlesElement.lastChild); 73 | } 74 | 75 | if (cuesByTime === null || videoState.time === null || !isFinite(videoState.time)) { 76 | return; 77 | } 78 | 79 | subtitlesElement.style.bottom = offset + '%'; 80 | subtitlesElement.style.opacity = opacity; 81 | subtitlesRenderer.render(cuesByTime, videoState.time - delay).forEach(function(cueNode) { 82 | cueNode.style.display = 'inline-block'; 83 | cueNode.style.padding = '0.2em'; 84 | cueNode.style.whiteSpace = 'pre-wrap'; 85 | var fontSizeMultiplier = window.screen720p ? 1.538 : 1; 86 | cueNode.style.fontSize = Math.floor((size / 25) * fontSizeMultiplier) + 'vmin'; 87 | cueNode.style.color = textColor; 88 | cueNode.style.backgroundColor = backgroundColor; 89 | cueNode.style.textShadow = '-0.15rem -0.15rem 0.15rem ' + outlineColor + ', 0px -0.15rem 0.15rem ' + outlineColor + ', 0.15rem -0.15rem 0.15rem ' + outlineColor + ', -0.15rem 0px 0.15rem ' + outlineColor + ', 0.15rem 0px 0.15rem ' + outlineColor + ', -0.15rem 0.15rem 0.15rem ' + outlineColor + ', 0px 0.15rem 0.15rem ' + outlineColor + ', 0.15rem 0.15rem 0.15rem ' + outlineColor; 90 | subtitlesElement.appendChild(cueNode); 91 | subtitlesElement.appendChild(document.createElement('br')); 92 | }); 93 | } 94 | function onVideoError(error) { 95 | events.emit('error', error); 96 | if (error.critical) { 97 | command('unload'); 98 | } 99 | } 100 | function onVideoPropEvent(eventName, propName, propValue) { 101 | switch (propName) { 102 | case 'time': { 103 | videoState.time = propValue; 104 | renderSubtitles(); 105 | break; 106 | } 107 | } 108 | 109 | events.emit(eventName, propName, getProp(propName, propValue)); 110 | } 111 | function onOtherVideoEvent(eventName) { 112 | return function() { 113 | events.emit.apply(events, [eventName].concat(Array.from(arguments))); 114 | }; 115 | } 116 | function onPropChanged(propName) { 117 | if (observedProps[propName]) { 118 | events.emit('propChanged', propName, getProp(propName, null)); 119 | } 120 | } 121 | function onError(error) { 122 | events.emit('error', error); 123 | if (error.critical) { 124 | command('unload'); 125 | video.dispatch({ type: 'command', commandName: 'unload' }); 126 | } 127 | } 128 | function getProp(propName, videoPropValue) { 129 | switch (propName) { 130 | case 'extraSubtitlesTracks': { 131 | if (destroyed) { 132 | return []; 133 | } 134 | 135 | return tracks.slice(); 136 | } 137 | case 'selectedExtraSubtitlesTrackId': { 138 | if (destroyed) { 139 | return null; 140 | } 141 | 142 | return selectedTrackId; 143 | } 144 | case 'extraSubtitlesDelay': { 145 | if (destroyed) { 146 | return null; 147 | } 148 | 149 | return delay; 150 | } 151 | case 'extraSubtitlesSize': { 152 | if (destroyed) { 153 | return null; 154 | } 155 | 156 | return size; 157 | } 158 | case 'extraSubtitlesOffset': { 159 | if (destroyed) { 160 | return null; 161 | } 162 | 163 | return offset; 164 | } 165 | case 'extraSubtitlesTextColor': { 166 | if (destroyed) { 167 | return null; 168 | } 169 | 170 | return textColor; 171 | } 172 | case 'extraSubtitlesBackgroundColor': { 173 | if (destroyed) { 174 | return null; 175 | } 176 | 177 | return backgroundColor; 178 | } 179 | case 'extraSubtitlesOutlineColor': { 180 | if (destroyed) { 181 | return null; 182 | } 183 | 184 | return outlineColor; 185 | } 186 | case 'extraSubtitlesOpacity': { 187 | if (destroyed) { 188 | return null; 189 | } 190 | 191 | return opacity; 192 | } 193 | default: { 194 | return videoPropValue; 195 | } 196 | } 197 | } 198 | function observeProp(propName) { 199 | switch (propName) { 200 | case 'extraSubtitlesTracks': 201 | case 'selectedExtraSubtitlesTrackId': 202 | case 'extraSubtitlesDelay': 203 | case 'extraSubtitlesSize': 204 | case 'extraSubtitlesOffset': 205 | case 'extraSubtitlesTextColor': 206 | case 'extraSubtitlesBackgroundColor': 207 | case 'extraSubtitlesOutlineColor': 208 | case 'extraSubtitlesOpacity': { 209 | events.emit('propValue', propName, getProp(propName, null)); 210 | observedProps[propName] = true; 211 | return true; 212 | } 213 | default: { 214 | return false; 215 | } 216 | } 217 | } 218 | function setProp(propName, propValue) { 219 | switch (propName) { 220 | case 'selectedExtraSubtitlesTrackId': { 221 | cuesByTime = null; 222 | selectedTrackId = null; 223 | delay = null; 224 | var selectedTrack = tracks.find(function(track) { 225 | return track.id === propValue; 226 | }); 227 | if (selectedTrack) { 228 | selectedTrackId = selectedTrack.id; 229 | delay = 0; 230 | 231 | function getSubtitlesData(track, isFallback) { 232 | var url = isFallback ? track.fallbackUrl : track.url; 233 | 234 | if (typeof url === 'string') { 235 | return fetch(url) 236 | .then(function(resp) { 237 | if (resp.ok) { 238 | return resp.text(); 239 | } 240 | 241 | throw new Error(resp.status + ' (' + resp.statusText + ')'); 242 | }); 243 | } 244 | 245 | if (track.buffer instanceof ArrayBuffer) { 246 | try { 247 | const uInt8Array = new Uint8Array(track.buffer); 248 | const text = new TextDecoder().decode(uInt8Array); 249 | return Promise.resolve(text); 250 | } catch(e) { 251 | return Promise.reject(e); 252 | } 253 | } 254 | 255 | return Promise.reject('No `url` or `buffer` field available for this track'); 256 | } 257 | 258 | function loadSubtitles(track, isFallback) { 259 | getSubtitlesData(track, isFallback) 260 | .then(function(text) { 261 | return subtitlesConverter.convert(text); 262 | }) 263 | .then(function(text) { 264 | return subtitlesParser.parse(text); 265 | }) 266 | .then(function(result) { 267 | if (selectedTrackId !== selectedTrack.id) { 268 | return; 269 | } 270 | 271 | cuesByTime = result; 272 | renderSubtitles(); 273 | events.emit('extraSubtitlesTrackLoaded', selectedTrack); 274 | }) 275 | .catch(function(error) { 276 | if (selectedTrackId !== selectedTrack.id) { 277 | return; 278 | } 279 | 280 | if (!isFallback && typeof selectedTrack.fallbackUrl === 'string') { 281 | loadSubtitles(selectedTrack, true); 282 | return; 283 | } 284 | 285 | onError(Object.assign({}, ERROR.WITH_HTML_SUBTITLES.LOAD_FAILED, { 286 | error: error, 287 | track: selectedTrack, 288 | critical: false 289 | })); 290 | }); 291 | } 292 | loadSubtitles(selectedTrack); 293 | } 294 | renderSubtitles(); 295 | onPropChanged('selectedExtraSubtitlesTrackId'); 296 | onPropChanged('extraSubtitlesDelay'); 297 | return true; 298 | } 299 | case 'extraSubtitlesDelay': { 300 | if (selectedTrackId !== null && propValue !== null && isFinite(propValue)) { 301 | delay = parseInt(propValue, 10); 302 | renderSubtitles(); 303 | onPropChanged('extraSubtitlesDelay'); 304 | } 305 | 306 | return true; 307 | } 308 | case 'extraSubtitlesSize': { 309 | if (propValue !== null && isFinite(propValue)) { 310 | size = Math.max(0, parseInt(propValue, 10)); 311 | renderSubtitles(); 312 | onPropChanged('extraSubtitlesSize'); 313 | } 314 | 315 | return true; 316 | } 317 | case 'extraSubtitlesOffset': { 318 | if (propValue !== null && isFinite(propValue)) { 319 | offset = Math.max(0, Math.min(100, parseInt(propValue, 10))); 320 | renderSubtitles(); 321 | onPropChanged('extraSubtitlesOffset'); 322 | } 323 | 324 | return true; 325 | } 326 | case 'extraSubtitlesTextColor': { 327 | if (typeof propValue === 'string') { 328 | try { 329 | textColor = Color(propValue).rgb().string(); 330 | } catch (error) { 331 | // eslint-disable-next-line no-console 332 | console.error('withHTMLSubtitles', error); 333 | } 334 | 335 | renderSubtitles(); 336 | onPropChanged('extraSubtitlesTextColor'); 337 | } 338 | 339 | return true; 340 | } 341 | case 'extraSubtitlesBackgroundColor': { 342 | if (typeof propValue === 'string') { 343 | try { 344 | backgroundColor = Color(propValue).rgb().string(); 345 | } catch (error) { 346 | // eslint-disable-next-line no-console 347 | console.error('withHTMLSubtitles', error); 348 | } 349 | 350 | renderSubtitles(); 351 | onPropChanged('extraSubtitlesBackgroundColor'); 352 | } 353 | 354 | return true; 355 | } 356 | case 'extraSubtitlesOutlineColor': { 357 | if (typeof propValue === 'string') { 358 | try { 359 | outlineColor = Color(propValue).rgb().string(); 360 | } catch (error) { 361 | // eslint-disable-next-line no-console 362 | console.error('withHTMLSubtitles', error); 363 | } 364 | 365 | renderSubtitles(); 366 | onPropChanged('extraSubtitlesOutlineColor'); 367 | } 368 | 369 | return true; 370 | } 371 | case 'extraSubtitlesOpacity': { 372 | if (typeof propValue === 'number') { 373 | try { 374 | opacity = Math.min(Math.max(propValue / 100, 0), 1); 375 | } catch (error) { 376 | // eslint-disable-next-line no-console 377 | console.error('withHTMLSubtitles', error); 378 | } 379 | 380 | renderSubtitles(); 381 | onPropChanged('extraSubtitlesOpacity'); 382 | } 383 | 384 | return true; 385 | } 386 | default: { 387 | return false; 388 | } 389 | } 390 | } 391 | function command(commandName, commandArgs) { 392 | switch (commandName) { 393 | case 'addExtraSubtitlesTracks': { 394 | if (commandArgs && Array.isArray(commandArgs.tracks)) { 395 | tracks = tracks 396 | .concat(commandArgs.tracks) 397 | .filter(function(track, index, tracks) { 398 | return track && 399 | typeof track.id === 'string' && 400 | typeof track.lang === 'string' && 401 | typeof track.label === 'string' && 402 | typeof track.origin === 'string' && 403 | !track.embedded && 404 | index === tracks.findIndex(function(t) { return t.id === track.id; }); 405 | }); 406 | onPropChanged('extraSubtitlesTracks'); 407 | } 408 | 409 | return true; 410 | } 411 | case 'addLocalSubtitles': { 412 | if (commandArgs && typeof commandArgs.filename === 'string' && commandArgs.buffer instanceof ArrayBuffer) { 413 | var id = 'LOCAL_' + tracks 414 | .filter(function(track) { return track.local; }) 415 | .length; 416 | 417 | var track = { 418 | id: id, 419 | url: null, 420 | buffer: commandArgs.buffer, 421 | lang: 'local', 422 | label: commandArgs.filename, 423 | origin: 'LOCAL', 424 | local: true, 425 | embedded: false, 426 | }; 427 | 428 | tracks.push(track); 429 | 430 | onPropChanged('extraSubtitlesTracks'); 431 | events.emit('extraSubtitlesTrackAdded', track); 432 | } 433 | 434 | return true; 435 | } 436 | case 'load': { 437 | command('unload'); 438 | if (commandArgs.stream && Array.isArray(commandArgs.stream.subtitles)) { 439 | command('addExtraSubtitlesTracks', { 440 | tracks: commandArgs.stream.subtitles.map(function(track) { 441 | return Object.assign({}, track, { 442 | origin: 'EXCLUSIVE', 443 | exclusive: true, 444 | embedded: false 445 | }); 446 | }) 447 | }); 448 | } 449 | 450 | return false; 451 | } 452 | case 'unload': { 453 | cuesByTime = null; 454 | tracks = []; 455 | selectedTrackId = null; 456 | delay = null; 457 | renderSubtitles(); 458 | onPropChanged('extraSubtitlesTracks'); 459 | onPropChanged('selectedExtraSubtitlesTrackId'); 460 | onPropChanged('extraSubtitlesDelay'); 461 | return false; 462 | } 463 | case 'destroy': { 464 | command('unload'); 465 | destroyed = true; 466 | onPropChanged('extraSubtitlesSize'); 467 | onPropChanged('extraSubtitlesOffset'); 468 | onPropChanged('extraSubtitlesTextColor'); 469 | onPropChanged('extraSubtitlesBackgroundColor'); 470 | onPropChanged('extraSubtitlesOutlineColor'); 471 | onPropChanged('extraSubtitlesOpacity'); 472 | video.dispatch({ type: 'command', commandName: 'destroy' }); 473 | events.removeAllListeners(); 474 | containerElement.removeChild(subtitlesElement); 475 | return true; 476 | } 477 | default: { 478 | return false; 479 | } 480 | } 481 | } 482 | 483 | this.on = function(eventName, listener) { 484 | if (destroyed) { 485 | throw new Error('Video is destroyed'); 486 | } 487 | 488 | events.on(eventName, listener); 489 | }; 490 | this.dispatch = function(action) { 491 | if (destroyed) { 492 | throw new Error('Video is destroyed'); 493 | } 494 | 495 | if (action) { 496 | action = deepFreeze(cloneDeep(action)); 497 | switch (action.type) { 498 | case 'observeProp': { 499 | if (observeProp(action.propName)) { 500 | return; 501 | } 502 | 503 | break; 504 | } 505 | case 'setProp': { 506 | if (setProp(action.propName, action.propValue)) { 507 | return; 508 | } 509 | 510 | break; 511 | } 512 | case 'command': { 513 | if (command(action.commandName, action.commandArgs)) { 514 | return; 515 | } 516 | 517 | break; 518 | } 519 | } 520 | } 521 | 522 | video.dispatch(action); 523 | }; 524 | } 525 | 526 | VideoWithHTMLSubtitles.canPlayStream = function(stream) { 527 | return Video.canPlayStream(stream); 528 | }; 529 | 530 | VideoWithHTMLSubtitles.manifest = { 531 | name: Video.manifest.name + 'WithHTMLSubtitles', 532 | external: Video.manifest.external, 533 | props: Video.manifest.props.concat(['extraSubtitlesTracks', 'selectedExtraSubtitlesTrackId', 'extraSubtitlesDelay', 'extraSubtitlesSize', 'extraSubtitlesOffset', 'extraSubtitlesTextColor', 'extraSubtitlesBackgroundColor', 'extraSubtitlesOutlineColor', 'extraSubtitlesOpacity']) 534 | .filter(function(value, index, array) { return array.indexOf(value) === index; }), 535 | commands: Video.manifest.commands.concat(['load', 'unload', 'destroy', 'addExtraSubtitlesTracks', 'addLocalSubtitles']) 536 | .filter(function(value, index, array) { return array.indexOf(value) === index; }), 537 | events: Video.manifest.events.concat(['propValue', 'propChanged', 'error', 'extraSubtitlesTrackLoaded', 'extraSubtitlesTrackAdded']) 538 | .filter(function(value, index, array) { return array.indexOf(value) === index; }) 539 | }; 540 | 541 | return VideoWithHTMLSubtitles; 542 | } 543 | 544 | module.exports = withHTMLSubtitles; 545 | -------------------------------------------------------------------------------- /src/TitanVideo/TitanVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var cloneDeep = require('lodash.clonedeep'); 3 | var deepFreeze = require('deep-freeze'); 4 | var Color = require('color'); 5 | var ERROR = require('../error'); 6 | 7 | var SSA_DESCRIPTORS_REGEX = /^\{(\\an[1-8])+\}/i; 8 | 9 | function TitanVideo(options) { 10 | options = options || {}; 11 | 12 | var size = 100; 13 | var offset = 0; 14 | var textColor = 'rgb(255, 255, 255)'; 15 | var backgroundColor = 'rgba(0, 0, 0, 0)'; 16 | var outlineColor = 'rgb(34, 34, 34)'; 17 | var subtitlesOpacity = 1; 18 | 19 | var containerElement = options.containerElement; 20 | if (!(containerElement instanceof HTMLElement)) { 21 | throw new Error('Container element required to be instance of HTMLElement'); 22 | } 23 | 24 | var videoElement = document.createElement('video'); 25 | videoElement.style.width = '100%'; 26 | videoElement.style.height = '100%'; 27 | videoElement.style.backgroundColor = 'black'; 28 | videoElement.controls = false; 29 | videoElement.playsInline = true; 30 | videoElement.onerror = function() { 31 | onVideoError(); 32 | }; 33 | videoElement.onended = function() { 34 | onEnded(); 35 | }; 36 | videoElement.onpause = function() { 37 | onPropChanged('paused'); 38 | }; 39 | videoElement.onplay = function() { 40 | onPropChanged('paused'); 41 | }; 42 | videoElement.ontimeupdate = function() { 43 | onPropChanged('time'); 44 | }; 45 | videoElement.ondurationchange = function() { 46 | onPropChanged('duration'); 47 | }; 48 | videoElement.onwaiting = function() { 49 | onPropChanged('buffering'); 50 | }; 51 | videoElement.onseeking = function() { 52 | onPropChanged('time'); 53 | onPropChanged('buffering'); 54 | }; 55 | videoElement.onseeked = function() { 56 | onPropChanged('time'); 57 | onPropChanged('buffering'); 58 | }; 59 | videoElement.onstalled = function() { 60 | onPropChanged('buffering'); 61 | }; 62 | videoElement.onplaying = function() { 63 | onPropChanged('time'); 64 | onPropChanged('buffering'); 65 | }; 66 | videoElement.oncanplay = function() { 67 | onPropChanged('buffering'); 68 | }; 69 | videoElement.canplaythrough = function() { 70 | onPropChanged('buffering'); 71 | }; 72 | videoElement.onloadedmetadata = function() { 73 | onPropChanged('loaded'); 74 | }; 75 | videoElement.onloadeddata = function() { 76 | onPropChanged('buffering'); 77 | }; 78 | videoElement.onvolumechange = function() { 79 | onPropChanged('volume'); 80 | onPropChanged('muted'); 81 | }; 82 | videoElement.onratechange = function() { 83 | onPropChanged('playbackSpeed'); 84 | }; 85 | videoElement.textTracks.onchange = function() { 86 | onPropChanged('subtitlesTracks'); 87 | onPropChanged('selectedSubtitlesTrackId'); 88 | }; 89 | containerElement.appendChild(videoElement); 90 | 91 | var subtitlesElement = document.createElement('div'); 92 | subtitlesElement.style.position = 'absolute'; 93 | subtitlesElement.style.right = '0'; 94 | subtitlesElement.style.bottom = '0'; 95 | subtitlesElement.style.left = '0'; 96 | subtitlesElement.style.zIndex = '1'; 97 | subtitlesElement.style.textAlign = 'center'; 98 | containerElement.style.position = 'relative'; 99 | containerElement.style.zIndex = '0'; 100 | containerElement.appendChild(subtitlesElement); 101 | 102 | var events = new EventEmitter(); 103 | var destroyed = false; 104 | var stream = null; 105 | var observedProps = { 106 | stream: false, 107 | loaded: false, 108 | paused: false, 109 | time: false, 110 | duration: false, 111 | buffering: false, 112 | subtitlesTracks: false, 113 | selectedSubtitlesTrackId: false, 114 | subtitlesOffset: false, 115 | subtitlesSize: false, 116 | subtitlesTextColor: false, 117 | subtitlesBackgroundColor: false, 118 | subtitlesOutlineColor: false, 119 | audioTracks: false, 120 | selectedAudioTrackId: false, 121 | volume: false, 122 | muted: false, 123 | playbackSpeed: false 124 | }; 125 | 126 | var lastSub; 127 | var disabledSubs = false; 128 | 129 | async function refreshSubtitle() { 130 | if (lastSub) { 131 | renderSubtitle(lastSub.text, 'show'); 132 | } 133 | } 134 | 135 | async function renderSubtitle(text, visibility) { 136 | if (disabledSubs) return; 137 | if (visibility === 'hide') { 138 | while (subtitlesElement.hasChildNodes()) { 139 | subtitlesElement.removeChild(subtitlesElement.lastChild); 140 | } 141 | lastSub = null; 142 | return; 143 | } 144 | 145 | lastSub = { 146 | text: text, 147 | }; 148 | 149 | while (subtitlesElement.hasChildNodes()) { 150 | subtitlesElement.removeChild(subtitlesElement.lastChild); 151 | } 152 | 153 | subtitlesElement.style.bottom = offset + '%'; 154 | subtitlesElement.style.opacity = subtitlesOpacity; 155 | 156 | var cueNode = document.createElement('span'); 157 | cueNode.innerHTML = text; 158 | cueNode.style.display = 'inline-block'; 159 | cueNode.style.padding = '0.2em'; 160 | cueNode.style.fontSize = Math.floor(size / 25) + 'vmin'; 161 | cueNode.style.color = textColor; 162 | cueNode.style.backgroundColor = backgroundColor; 163 | cueNode.style.textShadow = '1px 1px 0.1em ' + outlineColor; 164 | cueNode.style.whiteSpace = 'pre-wrap'; 165 | 166 | subtitlesElement.appendChild(cueNode); 167 | subtitlesElement.appendChild(document.createElement('br')); 168 | 169 | } 170 | 171 | function renderCue(ev) { 172 | var cues = (ev.target || {}).activeCues; 173 | if (!cues.length) { 174 | renderSubtitle('', 'hide'); 175 | } else { 176 | if (cues.length > 3) { 177 | // most probably SSA/ASS subs glitch 178 | ev.target.removeEventListener('cuechange', renderCue); 179 | renderSubtitle('', 'hide'); 180 | return; 181 | } 182 | var text = ''; 183 | for (var i in cues) { 184 | var cue = cues[i]; 185 | if (cue.text) { 186 | var cleanedText = cue.text.replace(SSA_DESCRIPTORS_REGEX, ''); 187 | text += (text ? '\n' : '') + cleanedText; 188 | } 189 | } 190 | renderSubtitle(text, 'show'); 191 | } 192 | } 193 | 194 | function getProp(propName) { 195 | switch (propName) { 196 | case 'stream': { 197 | return stream; 198 | } 199 | case 'loaded': { 200 | if (stream === null) { 201 | return null; 202 | } 203 | 204 | return videoElement.readyState >= videoElement.HAVE_METADATA; 205 | } 206 | case 'paused': { 207 | if (stream === null) { 208 | return null; 209 | } 210 | 211 | return !!videoElement.paused; 212 | } 213 | case 'time': { 214 | if (stream === null || videoElement.currentTime === null || !isFinite(videoElement.currentTime)) { 215 | return null; 216 | } 217 | 218 | return Math.floor(videoElement.currentTime * 1000); 219 | } 220 | case 'duration': { 221 | if (stream === null || videoElement.duration === null || !isFinite(videoElement.duration)) { 222 | return null; 223 | } 224 | 225 | return Math.floor(videoElement.duration * 1000); 226 | } 227 | case 'buffering': { 228 | if (stream === null) { 229 | return null; 230 | } 231 | 232 | return videoElement.readyState < videoElement.HAVE_FUTURE_DATA; 233 | } 234 | case 'subtitlesTracks': { 235 | if (stream === null) { 236 | return []; 237 | } 238 | 239 | if (!videoElement.textTracks || !Array.from(videoElement.textTracks).length) { 240 | return []; 241 | } 242 | 243 | return Array.from(videoElement.textTracks) 244 | .filter(function(track) { 245 | return track.kind === 'subtitles'; 246 | }) 247 | .map(function(track, index) { 248 | return Object.freeze({ 249 | id: 'EMBEDDED_' + String(index), 250 | lang: track.language, 251 | label: track.label || null, 252 | origin: 'EMBEDDED', 253 | embedded: true 254 | }); 255 | }); 256 | } 257 | case 'selectedSubtitlesTrackId': { 258 | if (stream === null) { 259 | return null; 260 | } 261 | 262 | if (!videoElement.textTracks || !Array.from(videoElement.textTracks).length) { 263 | return null; 264 | } 265 | 266 | return Array.from(videoElement.textTracks) 267 | .reduce(function(result, track, index) { 268 | if (result === null && track.mode === 'hidden') { 269 | return 'EMBEDDED_' + String(index); 270 | } 271 | 272 | return result; 273 | }, null); 274 | } 275 | case 'subtitlesOffset': { 276 | if (destroyed) { 277 | return null; 278 | } 279 | 280 | return offset; 281 | } 282 | case 'subtitlesSize': { 283 | if (destroyed) { 284 | return null; 285 | } 286 | 287 | return size; 288 | } 289 | case 'subtitlesTextColor': { 290 | if (destroyed) { 291 | return null; 292 | } 293 | 294 | return textColor; 295 | } 296 | case 'subtitlesBackgroundColor': { 297 | if (destroyed) { 298 | return null; 299 | } 300 | 301 | return backgroundColor; 302 | } 303 | case 'subtitlesOutlineColor': { 304 | if (destroyed) { 305 | return null; 306 | } 307 | 308 | return outlineColor; 309 | } 310 | case 'subtitlesOpacity': { 311 | if (destroyed) { 312 | return null; 313 | } 314 | 315 | return subtitlesOpacity; 316 | } 317 | case 'audioTracks': { 318 | if (stream === null) { 319 | return []; 320 | } 321 | 322 | if (!videoElement.audioTracks || !Array.from(videoElement.audioTracks).length) { 323 | return []; 324 | } 325 | 326 | return Array.from(videoElement.audioTracks) 327 | .map(function(track, index) { 328 | return Object.freeze({ 329 | id: 'EMBEDDED_' + String(index), 330 | lang: track.language, 331 | label: track.label || null, 332 | origin: 'EMBEDDED', 333 | embedded: true 334 | }); 335 | }); 336 | } 337 | case 'selectedAudioTrackId': { 338 | 339 | if (stream === null) { 340 | return null; 341 | } 342 | 343 | if (!videoElement.audioTracks || !Array.from(videoElement.audioTracks).length) { 344 | return null; 345 | } 346 | 347 | return Array.from(videoElement.audioTracks) 348 | .reduce(function(result, track, index) { 349 | if (result === null && track.enabled) { 350 | return 'EMBEDDED_' + String(index); 351 | } 352 | 353 | return result; 354 | }, null); 355 | } 356 | case 'volume': { 357 | if (destroyed || videoElement.volume === null || !isFinite(videoElement.volume)) { 358 | return null; 359 | } 360 | 361 | return Math.floor(videoElement.volume * 100); 362 | } 363 | case 'muted': { 364 | if (destroyed) { 365 | return null; 366 | } 367 | 368 | return !!videoElement.muted; 369 | } 370 | case 'playbackSpeed': { 371 | if (destroyed || videoElement.playbackRate === null || !isFinite(videoElement.playbackRate)) { 372 | return null; 373 | } 374 | 375 | return videoElement.playbackRate; 376 | } 377 | default: { 378 | return null; 379 | } 380 | } 381 | } 382 | function onVideoError() { 383 | if (destroyed) { 384 | return; 385 | } 386 | 387 | var error; 388 | switch (videoElement.error.code) { 389 | case 1: { 390 | error = ERROR.HTML_VIDEO.MEDIA_ERR_ABORTED; 391 | break; 392 | } 393 | case 2: { 394 | error = ERROR.HTML_VIDEO.MEDIA_ERR_NETWORK; 395 | break; 396 | } 397 | case 3: { 398 | error = ERROR.HTML_VIDEO.MEDIA_ERR_DECODE; 399 | break; 400 | } 401 | case 4: { 402 | error = ERROR.HTML_VIDEO.MEDIA_ERR_SRC_NOT_SUPPORTED; 403 | break; 404 | } 405 | default: { 406 | error = ERROR.UNKNOWN_ERROR; 407 | } 408 | } 409 | onError(Object.assign({}, error, { 410 | critical: true, 411 | error: videoElement.error 412 | })); 413 | } 414 | function onError(error) { 415 | events.emit('error', error); 416 | if (error.critical) { 417 | command('unload'); 418 | } 419 | } 420 | function onEnded() { 421 | events.emit('ended'); 422 | } 423 | function onPropChanged(propName) { 424 | if (observedProps[propName]) { 425 | events.emit('propChanged', propName, getProp(propName)); 426 | } 427 | } 428 | function observeProp(propName) { 429 | if (observedProps.hasOwnProperty(propName)) { 430 | events.emit('propValue', propName, getProp(propName)); 431 | observedProps[propName] = true; 432 | } 433 | } 434 | function setProp(propName, propValue) { 435 | switch (propName) { 436 | case 'paused': { 437 | if (stream !== null) { 438 | propValue ? videoElement.pause() : videoElement.play(); 439 | onPropChanged('paused'); 440 | } 441 | 442 | break; 443 | } 444 | case 'time': { 445 | if (stream !== null && propValue !== null && isFinite(propValue)) { 446 | renderSubtitle('', 'hide'); 447 | videoElement.currentTime = parseInt(propValue, 10) / 1000; 448 | onPropChanged('time'); 449 | } 450 | 451 | break; 452 | } 453 | case 'selectedSubtitlesTrackId': { 454 | if (stream !== null) { 455 | Array.from(videoElement.textTracks) 456 | .forEach(function(track, index) { 457 | if (track.mode === 'hidden') { 458 | track.removeEventListener('cuechange', renderCue); 459 | } 460 | track.mode = 'EMBEDDED_' + String(index) === propValue ? 'hidden' : 'disabled'; 461 | if (track.mode === 'hidden') { 462 | track.addEventListener('cuechange', renderCue); 463 | } 464 | }); 465 | var selectedSubtitlesTrack = getProp('subtitlesTracks') 466 | .find(function(track) { 467 | return track.id === propValue; 468 | }); 469 | 470 | renderSubtitle('', 'hide'); 471 | 472 | if (selectedSubtitlesTrack) { 473 | onPropChanged('selectedSubtitlesTrackId'); 474 | events.emit('subtitlesTrackLoaded', selectedSubtitlesTrack); 475 | } 476 | } 477 | 478 | break; 479 | } 480 | case 'subtitlesOffset': { 481 | if (propValue !== null && isFinite(propValue)) { 482 | offset = Math.max(0, Math.min(100, parseInt(propValue, 10))); 483 | refreshSubtitle(); 484 | onPropChanged('subtitlesOffset'); 485 | } 486 | 487 | break; 488 | } 489 | case 'subtitlesSize': { 490 | if (propValue !== null && isFinite(propValue)) { 491 | size = Math.max(0, parseInt(propValue, 10)); 492 | refreshSubtitle(); 493 | onPropChanged('subtitlesSize'); 494 | } 495 | 496 | break; 497 | } 498 | case 'subtitlesTextColor': { 499 | if (typeof propValue === 'string') { 500 | try { 501 | textColor = Color(propValue).rgb().string(); 502 | } catch (error) { 503 | // eslint-disable-next-line no-console 504 | console.error('Tizen player with HTML Subtitles', error); 505 | } 506 | 507 | refreshSubtitle(); 508 | onPropChanged('subtitlesTextColor'); 509 | } 510 | 511 | break; 512 | } 513 | case 'subtitlesBackgroundColor': { 514 | if (typeof propValue === 'string') { 515 | try { 516 | backgroundColor = Color(propValue).rgb().string(); 517 | } catch (error) { 518 | // eslint-disable-next-line no-console 519 | console.error('Tizen player with HTML Subtitles', error); 520 | } 521 | 522 | refreshSubtitle(); 523 | 524 | onPropChanged('subtitlesBackgroundColor'); 525 | } 526 | 527 | break; 528 | } 529 | case 'subtitlesOutlineColor': { 530 | if (typeof propValue === 'string') { 531 | try { 532 | outlineColor = Color(propValue).rgb().string(); 533 | } catch (error) { 534 | // eslint-disable-next-line no-console 535 | console.error('Tizen player with HTML Subtitles', error); 536 | } 537 | 538 | refreshSubtitle(); 539 | 540 | onPropChanged('subtitlesOutlineColor'); 541 | } 542 | 543 | break; 544 | } 545 | case 'subtitlesOpacity': { 546 | if (typeof propValue === 'number') { 547 | try { 548 | subtitlesOpacity = Math.min(Math.max(propValue / 100, 0), 1); 549 | } catch (error) { 550 | // eslint-disable-next-line no-console 551 | console.error('Tizen player with HTML Subtitles', error); 552 | } 553 | 554 | refreshSubtitle(); 555 | 556 | onPropChanged('subtitlesOpacity'); 557 | } 558 | 559 | break; 560 | } 561 | case 'selectedAudioTrackId': { 562 | if (stream !== null) { 563 | for (var index = 0; index < videoElement.audioTracks.length; index++) { 564 | videoElement.audioTracks[index].enabled = !!('EMBEDDED_' + String(index) === propValue); 565 | } 566 | } 567 | 568 | var selectedAudioTrack = getProp('audioTracks') 569 | .find(function(track) { 570 | return track.id === propValue; 571 | }); 572 | 573 | if (selectedAudioTrack) { 574 | onPropChanged('selectedAudioTrackId'); 575 | events.emit('audioTrackLoaded', selectedAudioTrack); 576 | } 577 | 578 | break; 579 | } 580 | case 'volume': { 581 | if (propValue !== null && isFinite(propValue)) { 582 | videoElement.muted = false; 583 | videoElement.volume = Math.max(0, Math.min(100, parseInt(propValue, 10))) / 100; 584 | onPropChanged('muted'); 585 | onPropChanged('volume'); 586 | } 587 | 588 | break; 589 | } 590 | case 'muted': { 591 | videoElement.muted = !!propValue; 592 | onPropChanged('muted'); 593 | break; 594 | } 595 | case 'playbackSpeed': { 596 | if (propValue !== null && isFinite(propValue)) { 597 | videoElement.playbackRate = parseFloat(propValue); 598 | onPropChanged('playbackSpeed'); 599 | } 600 | 601 | break; 602 | } 603 | } 604 | } 605 | function command(commandName, commandArgs) { 606 | switch (commandName) { 607 | case 'load': { 608 | command('unload'); 609 | if (commandArgs && commandArgs.stream && typeof commandArgs.stream.url === 'string') { 610 | stream = commandArgs.stream; 611 | onPropChanged('stream'); 612 | onPropChanged('loaded'); 613 | videoElement.autoplay = typeof commandArgs.autoplay === 'boolean' ? commandArgs.autoplay : true; 614 | videoElement.currentTime = commandArgs.time !== null && isFinite(commandArgs.time) ? parseInt(commandArgs.time, 10) / 1000 : 0; 615 | onPropChanged('paused'); 616 | onPropChanged('time'); 617 | onPropChanged('duration'); 618 | onPropChanged('buffering'); 619 | if (videoElement.textTracks) { 620 | videoElement.textTracks.onaddtrack = function() { 621 | videoElement.textTracks.onaddtrack = null; 622 | setTimeout(function() { 623 | onPropChanged('subtitlesTracks'); 624 | onPropChanged('selectedSubtitlesTrackId'); 625 | }); 626 | }; 627 | } 628 | if (videoElement.audioTracks) { 629 | videoElement.audioTracks.onaddtrack = function() { 630 | videoElement.audioTracks.onaddtrack = null; 631 | setTimeout(function() { 632 | onPropChanged('audioTracks'); 633 | onPropChanged('selectedAudioTrackId'); 634 | }); 635 | }; 636 | } 637 | videoElement.src = stream.url; 638 | } else { 639 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 640 | critical: true, 641 | stream: commandArgs ? commandArgs.stream : null 642 | })); 643 | } 644 | break; 645 | } 646 | case 'unload': { 647 | stream = null; 648 | Array.from(videoElement.textTracks).forEach(function(track) { 649 | track.oncuechange = null; 650 | }); 651 | videoElement.removeAttribute('src'); 652 | videoElement.load(); 653 | videoElement.currentTime = 0; 654 | onPropChanged('stream'); 655 | onPropChanged('loaded'); 656 | onPropChanged('paused'); 657 | onPropChanged('time'); 658 | onPropChanged('duration'); 659 | onPropChanged('buffering'); 660 | onPropChanged('subtitlesTracks'); 661 | onPropChanged('selectedSubtitlesTrackId'); 662 | onPropChanged('audioTracks'); 663 | onPropChanged('selectedAudioTrackId'); 664 | break; 665 | } 666 | case 'destroy': { 667 | command('unload'); 668 | destroyed = true; 669 | onPropChanged('subtitlesOffset'); 670 | onPropChanged('subtitlesSize'); 671 | onPropChanged('subtitlesTextColor'); 672 | onPropChanged('subtitlesBackgroundColor'); 673 | onPropChanged('subtitlesOutlineColor'); 674 | onPropChanged('volume'); 675 | onPropChanged('muted'); 676 | onPropChanged('playbackSpeed'); 677 | events.removeAllListeners(); 678 | videoElement.onerror = null; 679 | videoElement.onended = null; 680 | videoElement.onpause = null; 681 | videoElement.onplay = null; 682 | videoElement.ontimeupdate = null; 683 | videoElement.ondurationchange = null; 684 | videoElement.onwaiting = null; 685 | videoElement.onseeking = null; 686 | videoElement.onseeked = null; 687 | videoElement.onstalled = null; 688 | videoElement.onplaying = null; 689 | videoElement.oncanplay = null; 690 | videoElement.canplaythrough = null; 691 | videoElement.onloadeddata = null; 692 | videoElement.onvolumechange = null; 693 | videoElement.onratechange = null; 694 | videoElement.textTracks.onchange = null; 695 | containerElement.removeChild(videoElement); 696 | break; 697 | } 698 | } 699 | } 700 | 701 | this.on = function(eventName, listener) { 702 | if (destroyed) { 703 | throw new Error('Video is destroyed'); 704 | } 705 | 706 | events.on(eventName, listener); 707 | }; 708 | this.dispatch = function(action) { 709 | if (destroyed) { 710 | throw new Error('Video is destroyed'); 711 | } 712 | 713 | if (action) { 714 | action = deepFreeze(cloneDeep(action)); 715 | switch (action.type) { 716 | case 'observeProp': { 717 | observeProp(action.propName); 718 | return; 719 | } 720 | case 'setProp': { 721 | setProp(action.propName, action.propValue); 722 | return; 723 | } 724 | case 'command': { 725 | command(action.commandName, action.commandArgs); 726 | return; 727 | } 728 | } 729 | } 730 | 731 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 732 | }; 733 | } 734 | 735 | TitanVideo.canPlayStream = function(stream) { 736 | if (!stream) { 737 | return Promise.resolve(false); 738 | } 739 | 740 | return Promise.resolve(true); 741 | }; 742 | 743 | TitanVideo.manifest = { 744 | name: 'TitanVideo', 745 | external: false, 746 | props: ['stream', 'loaded', 'paused', 'time', 'duration', 'buffering', 'audioTracks', 'selectedAudioTrackId', 'subtitlesTracks', 'selectedSubtitlesTrackId', 'subtitlesOffset', 'subtitlesSize', 'subtitlesTextColor', 'subtitlesBackgroundColor', 'subtitlesOutlineColor', 'subtitlesOpacity', 'volume', 'muted', 'playbackSpeed'], 747 | commands: ['load', 'unload', 'destroy'], 748 | events: ['propValue', 'propChanged', 'ended', 'error', 'subtitlesTrackLoaded', 'audioTrackLoaded'] 749 | }; 750 | 751 | module.exports = TitanVideo; 752 | -------------------------------------------------------------------------------- /src/HTMLVideo/HTMLVideo.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('eventemitter3'); 2 | var Hls = require('hls.js'); 3 | var cloneDeep = require('lodash.clonedeep'); 4 | var deepFreeze = require('deep-freeze'); 5 | var Color = require('color'); 6 | var ERROR = require('../error'); 7 | var getContentType = require('./getContentType'); 8 | var HLS_CONFIG = require('./hlsConfig'); 9 | 10 | function HTMLVideo(options) { 11 | options = options || {}; 12 | 13 | var containerElement = options.containerElement; 14 | if (!(containerElement instanceof HTMLElement)) { 15 | throw new Error('Container element required to be instance of HTMLElement'); 16 | } 17 | 18 | var styleElement = document.createElement('style'); 19 | containerElement.appendChild(styleElement); 20 | styleElement.sheet.insertRule('video::cue { font-size: 4vmin; color: rgb(255, 255, 255); background-color: rgba(0, 0, 0, 0); text-shadow: -0.15rem -0.15rem 0.15rem rgb(34, 34, 34), 0px -0.15rem 0.15rem rgb(34, 34, 34), 0.15rem -0.15rem 0.15rem rgb(34, 34, 34), -0.15rem 0px 0.15rem rgb(34, 34, 34), 0.15rem 0px 0.15rem rgb(34, 34, 34), -0.15rem 0.15rem 0.15rem rgb(34, 34, 34), 0px 0.15rem 0.15rem rgb(34, 34, 34), 0.15rem 0.15rem 0.15rem rgb(34, 34, 34); }'); 21 | var videoElement = document.createElement('video'); 22 | videoElement.style.width = '100%'; 23 | videoElement.style.height = '100%'; 24 | videoElement.style.backgroundColor = 'black'; 25 | videoElement.controls = false; 26 | videoElement.playsInline = true; 27 | videoElement.onerror = function() { 28 | onVideoError(); 29 | }; 30 | videoElement.onended = function() { 31 | onEnded(); 32 | }; 33 | videoElement.onpause = function() { 34 | onPropChanged('paused'); 35 | }; 36 | videoElement.onplay = function() { 37 | onPropChanged('paused'); 38 | }; 39 | videoElement.ontimeupdate = function() { 40 | onPropChanged('time'); 41 | onPropChanged('buffered'); 42 | }; 43 | videoElement.ondurationchange = function() { 44 | onPropChanged('duration'); 45 | }; 46 | videoElement.onwaiting = function() { 47 | onPropChanged('buffering'); 48 | onPropChanged('buffered'); 49 | }; 50 | videoElement.onseeking = function() { 51 | onPropChanged('time'); 52 | onPropChanged('buffering'); 53 | onPropChanged('buffered'); 54 | }; 55 | videoElement.onseeked = function() { 56 | onPropChanged('time'); 57 | onPropChanged('buffering'); 58 | onPropChanged('buffered'); 59 | }; 60 | videoElement.onstalled = function() { 61 | onPropChanged('buffering'); 62 | onPropChanged('buffered'); 63 | }; 64 | videoElement.onplaying = function() { 65 | onPropChanged('time'); 66 | onPropChanged('buffering'); 67 | onPropChanged('buffered'); 68 | }; 69 | videoElement.oncanplay = function() { 70 | onPropChanged('buffering'); 71 | onPropChanged('buffered'); 72 | }; 73 | videoElement.canplaythrough = function() { 74 | onPropChanged('buffering'); 75 | onPropChanged('buffered'); 76 | }; 77 | videoElement.onloadedmetadata = function() { 78 | onPropChanged('loaded'); 79 | }; 80 | videoElement.onloadeddata = function() { 81 | onPropChanged('buffering'); 82 | onPropChanged('buffered'); 83 | }; 84 | videoElement.onvolumechange = function() { 85 | onPropChanged('volume'); 86 | onPropChanged('muted'); 87 | }; 88 | videoElement.onratechange = function() { 89 | onPropChanged('playbackSpeed'); 90 | }; 91 | videoElement.textTracks.onchange = function() { 92 | onPropChanged('subtitlesTracks'); 93 | onPropChanged('selectedSubtitlesTrackId'); 94 | onCueChange(); 95 | Array.from(videoElement.textTracks).forEach(function(track) { 96 | track.oncuechange = onCueChange; 97 | }); 98 | }; 99 | containerElement.appendChild(videoElement); 100 | 101 | var hls = null; 102 | var events = new EventEmitter(); 103 | var destroyed = false; 104 | var stream = null; 105 | var subtitlesOffset = 0; 106 | var subtitlesOpacity = 1; 107 | var observedProps = { 108 | stream: false, 109 | loaded: false, 110 | paused: false, 111 | time: false, 112 | duration: false, 113 | buffering: false, 114 | buffered: false, 115 | subtitlesTracks: false, 116 | selectedSubtitlesTrackId: false, 117 | subtitlesOffset: false, 118 | subtitlesSize: false, 119 | subtitlesTextColor: false, 120 | subtitlesBackgroundColor: false, 121 | subtitlesOutlineColor: false, 122 | audioTracks: false, 123 | selectedAudioTrackId: false, 124 | volume: false, 125 | muted: false, 126 | playbackSpeed: false 127 | }; 128 | 129 | function getProp(propName) { 130 | switch (propName) { 131 | case 'stream': { 132 | return stream; 133 | } 134 | case 'loaded': { 135 | if (stream === null) { 136 | return null; 137 | } 138 | 139 | return videoElement.readyState >= videoElement.HAVE_METADATA; 140 | } 141 | case 'paused': { 142 | if (stream === null) { 143 | return null; 144 | } 145 | 146 | return !!videoElement.paused; 147 | } 148 | case 'time': { 149 | if (stream === null || videoElement.currentTime === null || !isFinite(videoElement.currentTime)) { 150 | return null; 151 | } 152 | 153 | return Math.floor(videoElement.currentTime * 1000); 154 | } 155 | case 'duration': { 156 | if (stream === null || videoElement.duration === null || !isFinite(videoElement.duration)) { 157 | return null; 158 | } 159 | 160 | return Math.floor(videoElement.duration * 1000); 161 | } 162 | case 'buffering': { 163 | if (stream === null) { 164 | return null; 165 | } 166 | 167 | return videoElement.readyState < videoElement.HAVE_FUTURE_DATA; 168 | } 169 | case 'buffered': { 170 | if (stream === null) { 171 | return null; 172 | } 173 | 174 | var time = videoElement.currentTime !== null && isFinite(videoElement.currentTime) ? videoElement.currentTime : 0; 175 | for (var i = 0; i < videoElement.buffered.length; i++) { 176 | if (videoElement.buffered.start(i) <= time && time <= videoElement.buffered.end(i)) { 177 | return Math.floor(videoElement.buffered.end(i) * 1000); 178 | } 179 | } 180 | 181 | return Math.floor(time * 1000); 182 | } 183 | case 'subtitlesTracks': { 184 | if (stream === null) { 185 | return []; 186 | } 187 | 188 | return Array.from(videoElement.textTracks) 189 | .map(function(track, index) { 190 | return Object.freeze({ 191 | id: 'EMBEDDED_' + String(index), 192 | lang: track.language, 193 | label: track.label || null, 194 | origin: 'EMBEDDED', 195 | embedded: true 196 | }); 197 | }); 198 | } 199 | case 'selectedSubtitlesTrackId': { 200 | if (stream === null) { 201 | return null; 202 | } 203 | 204 | return Array.from(videoElement.textTracks) 205 | .reduce(function(result, track, index) { 206 | if (result === null && track.mode === 'showing') { 207 | return 'EMBEDDED_' + String(index); 208 | } 209 | 210 | return result; 211 | }, null); 212 | } 213 | case 'subtitlesOffset': { 214 | if (destroyed) { 215 | return null; 216 | } 217 | 218 | return subtitlesOffset; 219 | } 220 | case 'subtitlesSize': { 221 | if (destroyed) { 222 | return null; 223 | } 224 | 225 | return parseInt(styleElement.sheet.cssRules[0].style.fontSize, 10) * 25; 226 | } 227 | case 'subtitlesTextColor': { 228 | if (destroyed) { 229 | return null; 230 | } 231 | 232 | return styleElement.sheet.cssRules[0].style.color; 233 | } 234 | case 'subtitlesBackgroundColor': { 235 | if (destroyed) { 236 | return null; 237 | } 238 | 239 | return styleElement.sheet.cssRules[0].style.backgroundColor; 240 | } 241 | case 'subtitlesOutlineColor': { 242 | if (destroyed) { 243 | return null; 244 | } 245 | 246 | return styleElement.sheet.cssRules[0].style.textShadow.slice(0, styleElement.sheet.cssRules[0].style.textShadow.indexOf(')') + 1); 247 | } 248 | case 'subtitlesOpacity': { 249 | if (destroyed) { 250 | return null; 251 | } 252 | 253 | return Math.round(subtitlesOpacity * 100); 254 | } 255 | case 'audioTracks': { 256 | if (hls === null || !Array.isArray(hls.audioTracks)) { 257 | return []; 258 | } 259 | 260 | return hls.audioTracks 261 | .map(function(track) { 262 | return Object.freeze({ 263 | id: 'EMBEDDED_' + String(track.id), 264 | lang: typeof track.lang === 'string' && track.lang.length > 0 ? 265 | track.lang 266 | : 267 | typeof track.name === 'string' && track.name.length > 0 ? 268 | track.name 269 | : 270 | String(track.id), 271 | label: typeof track.name === 'string' && track.name.length > 0 ? 272 | track.name 273 | : 274 | typeof track.lang === 'string' && track.lang.length > 0 ? 275 | track.lang 276 | : 277 | String(track.id), 278 | origin: 'EMBEDDED', 279 | embedded: true 280 | }); 281 | }); 282 | } 283 | case 'selectedAudioTrackId': { 284 | if (hls === null || hls.audioTrack === null || !isFinite(hls.audioTrack) || hls.audioTrack === -1) { 285 | return null; 286 | } 287 | 288 | return 'EMBEDDED_' + String(hls.audioTrack); 289 | } 290 | case 'volume': { 291 | if (destroyed || videoElement.volume === null || !isFinite(videoElement.volume)) { 292 | return null; 293 | } 294 | 295 | return Math.floor(videoElement.volume * 100); 296 | } 297 | case 'muted': { 298 | if (destroyed) { 299 | return null; 300 | } 301 | 302 | return !!videoElement.muted; 303 | } 304 | case 'playbackSpeed': { 305 | if (destroyed || videoElement.playbackRate === null || !isFinite(videoElement.playbackRate)) { 306 | return null; 307 | } 308 | 309 | return videoElement.playbackRate; 310 | } 311 | default: { 312 | return null; 313 | } 314 | } 315 | } 316 | function onCueChange() { 317 | Array.from(videoElement.textTracks).forEach(function(track) { 318 | Array.from(track.cues || []).forEach(function(cue) { 319 | cue.snapToLines = false; 320 | cue.line = 100 - subtitlesOffset; 321 | }); 322 | }); 323 | } 324 | function onVideoError() { 325 | if (destroyed) { 326 | return; 327 | } 328 | 329 | var error; 330 | switch (videoElement.error.code) { 331 | case 1: { 332 | error = ERROR.HTML_VIDEO.MEDIA_ERR_ABORTED; 333 | break; 334 | } 335 | case 2: { 336 | error = ERROR.HTML_VIDEO.MEDIA_ERR_NETWORK; 337 | break; 338 | } 339 | case 3: { 340 | error = ERROR.HTML_VIDEO.MEDIA_ERR_DECODE; 341 | break; 342 | } 343 | case 4: { 344 | error = ERROR.HTML_VIDEO.MEDIA_ERR_SRC_NOT_SUPPORTED; 345 | break; 346 | } 347 | default: { 348 | error = ERROR.UNKNOWN_ERROR; 349 | } 350 | } 351 | onError(Object.assign({}, error, { 352 | critical: true, 353 | error: videoElement.error 354 | })); 355 | } 356 | function onError(error) { 357 | events.emit('error', error); 358 | if (error.critical) { 359 | command('unload'); 360 | } 361 | } 362 | function onEnded() { 363 | events.emit('ended'); 364 | } 365 | function onPropChanged(propName) { 366 | if (observedProps[propName]) { 367 | events.emit('propChanged', propName, getProp(propName)); 368 | } 369 | } 370 | function observeProp(propName) { 371 | if (observedProps.hasOwnProperty(propName)) { 372 | events.emit('propValue', propName, getProp(propName)); 373 | observedProps[propName] = true; 374 | } 375 | } 376 | function setProp(propName, propValue) { 377 | switch (propName) { 378 | case 'paused': { 379 | if (stream !== null) { 380 | propValue ? videoElement.pause() : videoElement.play(); 381 | onPropChanged('paused'); 382 | } 383 | 384 | break; 385 | } 386 | case 'time': { 387 | if (stream !== null && propValue !== null && isFinite(propValue)) { 388 | videoElement.currentTime = parseInt(propValue, 10) / 1000; 389 | onPropChanged('time'); 390 | } 391 | 392 | break; 393 | } 394 | case 'selectedSubtitlesTrackId': { 395 | if (stream !== null) { 396 | Array.from(videoElement.textTracks) 397 | .forEach(function(track, index) { 398 | track.mode = 'EMBEDDED_' + String(index) === propValue ? 'showing' : 'disabled'; 399 | }); 400 | var selecterdSubtitlesTrack = getProp('subtitlesTracks') 401 | .find(function(track) { 402 | return track.id === propValue; 403 | }); 404 | if (selecterdSubtitlesTrack) { 405 | onPropChanged('selectedSubtitlesTrackId'); 406 | events.emit('subtitlesTrackLoaded', selecterdSubtitlesTrack); 407 | } 408 | } 409 | 410 | break; 411 | } 412 | case 'subtitlesOffset': { 413 | if (propValue !== null && isFinite(propValue)) { 414 | subtitlesOffset = Math.max(0, Math.min(100, parseInt(propValue, 10))); 415 | onCueChange(); 416 | onPropChanged('subtitlesOffset'); 417 | } 418 | 419 | break; 420 | } 421 | case 'subtitlesSize': { 422 | if (propValue !== null && isFinite(propValue)) { 423 | styleElement.sheet.cssRules[0].style.fontSize = Math.floor(Math.max(0, parseInt(propValue, 10)) / 25) + 'vmin'; 424 | onPropChanged('subtitlesSize'); 425 | } 426 | 427 | break; 428 | } 429 | case 'subtitlesTextColor': { 430 | if (typeof propValue === 'string') { 431 | try { 432 | styleElement.sheet.cssRules[0].style.color = Color(propValue).rgb().string(); 433 | } catch (error) { 434 | // eslint-disable-next-line no-console 435 | console.error('HTMLVideo', error); 436 | } 437 | 438 | onPropChanged('subtitlesTextColor'); 439 | } 440 | 441 | break; 442 | } 443 | case 'subtitlesBackgroundColor': { 444 | if (typeof propValue === 'string') { 445 | try { 446 | styleElement.sheet.cssRules[0].style.backgroundColor = Color(propValue).rgb().string(); 447 | } catch (error) { 448 | // eslint-disable-next-line no-console 449 | console.error('HTMLVideo', error); 450 | } 451 | 452 | onPropChanged('subtitlesBackgroundColor'); 453 | } 454 | 455 | break; 456 | } 457 | case 'subtitlesOutlineColor': { 458 | if (typeof propValue === 'string') { 459 | try { 460 | var outlineColor = Color(propValue).rgb().string(); 461 | styleElement.sheet.cssRules[0].style.textShadow = '-0.15rem -0.15rem 0.15rem ' + outlineColor + ', 0px -0.15rem 0.15rem ' + outlineColor + ', 0.15rem -0.15rem 0.15rem ' + outlineColor + ', -0.15rem 0px 0.15rem ' + outlineColor + ', 0.15rem 0px 0.15rem ' + outlineColor + ', -0.15rem 0.15rem 0.15rem ' + outlineColor + ', 0px 0.15rem 0.15rem ' + outlineColor + ', 0.15rem 0.15rem 0.15rem ' + outlineColor; 462 | } catch (error) { 463 | // eslint-disable-next-line no-console 464 | console.error('HTMLVideo', error); 465 | } 466 | 467 | onPropChanged('subtitlesOutlineColor'); 468 | } 469 | 470 | break; 471 | } 472 | case 'subtitlesOpacity': { 473 | if (typeof propValue === 'number') { 474 | try { 475 | subtitlesOpacity = Math.min(Math.max(propValue / 100, 0), 1); 476 | styleElement.sheet.cssRules[0].style.opacity = subtitlesOpacity + ''; 477 | } catch (error) { 478 | // eslint-disable-next-line no-console 479 | console.error('VVideo with HTML Subtitles', error); 480 | } 481 | 482 | onPropChanged('subtitlesOpacity'); 483 | } 484 | 485 | break; 486 | } 487 | case 'selectedAudioTrackId': { 488 | if (hls !== null) { 489 | var selecterdAudioTrack = getProp('audioTracks') 490 | .find(function(track) { 491 | return track.id === propValue; 492 | }); 493 | hls.audioTrack = selecterdAudioTrack ? parseInt(selecterdAudioTrack.id.split('_').pop(), 10) : -1; 494 | if (selecterdAudioTrack) { 495 | onPropChanged('selectedAudioTrackId'); 496 | events.emit('audioTrackLoaded', selecterdAudioTrack); 497 | } 498 | } 499 | 500 | break; 501 | } 502 | case 'volume': { 503 | if (propValue !== null && isFinite(propValue)) { 504 | videoElement.muted = false; 505 | videoElement.volume = Math.max(0, Math.min(100, parseInt(propValue, 10))) / 100; 506 | onPropChanged('muted'); 507 | onPropChanged('volume'); 508 | } 509 | 510 | break; 511 | } 512 | case 'muted': { 513 | videoElement.muted = !!propValue; 514 | onPropChanged('muted'); 515 | break; 516 | } 517 | case 'playbackSpeed': { 518 | if (propValue !== null && isFinite(propValue)) { 519 | videoElement.playbackRate = parseFloat(propValue); 520 | onPropChanged('playbackSpeed'); 521 | } 522 | 523 | break; 524 | } 525 | } 526 | } 527 | function command(commandName, commandArgs) { 528 | switch (commandName) { 529 | case 'load': { 530 | command('unload'); 531 | if (commandArgs && commandArgs.stream && typeof commandArgs.stream.url === 'string') { 532 | stream = commandArgs.stream; 533 | onPropChanged('stream'); 534 | onPropChanged('loaded'); 535 | videoElement.autoplay = typeof commandArgs.autoplay === 'boolean' ? commandArgs.autoplay : true; 536 | videoElement.currentTime = commandArgs.time !== null && isFinite(commandArgs.time) ? parseInt(commandArgs.time, 10) / 1000 : 0; 537 | onPropChanged('paused'); 538 | onPropChanged('time'); 539 | onPropChanged('duration'); 540 | onPropChanged('buffering'); 541 | onPropChanged('buffered'); 542 | onPropChanged('subtitlesTracks'); 543 | onPropChanged('selectedSubtitlesTrackId'); 544 | onPropChanged('audioTracks'); 545 | onPropChanged('selectedAudioTrackId'); 546 | getContentType(stream) 547 | .then(function(contentType) { 548 | if (stream !== commandArgs.stream) { 549 | return; 550 | } 551 | 552 | if (contentType === 'application/vnd.apple.mpegurl' && Hls.isSupported()) { 553 | hls = new Hls(HLS_CONFIG); 554 | hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, function() { 555 | onPropChanged('audioTracks'); 556 | onPropChanged('selectedAudioTrackId'); 557 | }); 558 | hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, function() { 559 | onPropChanged('audioTracks'); 560 | onPropChanged('selectedAudioTrackId'); 561 | }); 562 | hls.loadSource(stream.url); 563 | hls.attachMedia(videoElement); 564 | } else { 565 | videoElement.src = stream.url; 566 | } 567 | }) 568 | .catch(function() { 569 | if (stream !== commandArgs.stream) { 570 | return; 571 | } 572 | 573 | videoElement.src = stream.url; 574 | }); 575 | } else { 576 | onError(Object.assign({}, ERROR.UNSUPPORTED_STREAM, { 577 | critical: true, 578 | stream: commandArgs ? commandArgs.stream : null 579 | })); 580 | } 581 | break; 582 | } 583 | case 'unload': { 584 | stream = null; 585 | Array.from(videoElement.textTracks).forEach(function(track) { 586 | track.oncuechange = null; 587 | }); 588 | if (hls !== null) { 589 | hls.removeAllListeners(); 590 | hls.detachMedia(videoElement); 591 | hls.destroy(); 592 | hls = null; 593 | } 594 | videoElement.removeAttribute('src'); 595 | videoElement.load(); 596 | videoElement.currentTime = 0; 597 | onPropChanged('stream'); 598 | onPropChanged('loaded'); 599 | onPropChanged('paused'); 600 | onPropChanged('time'); 601 | onPropChanged('duration'); 602 | onPropChanged('buffering'); 603 | onPropChanged('buffered'); 604 | onPropChanged('subtitlesTracks'); 605 | onPropChanged('selectedSubtitlesTrackId'); 606 | onPropChanged('audioTracks'); 607 | onPropChanged('selectedAudioTrackId'); 608 | break; 609 | } 610 | case 'destroy': { 611 | command('unload'); 612 | destroyed = true; 613 | onPropChanged('subtitlesOffset'); 614 | onPropChanged('subtitlesSize'); 615 | onPropChanged('subtitlesTextColor'); 616 | onPropChanged('subtitlesBackgroundColor'); 617 | onPropChanged('subtitlesOutlineColor'); 618 | onPropChanged('subtitlesOpacity'); 619 | onPropChanged('volume'); 620 | onPropChanged('muted'); 621 | onPropChanged('playbackSpeed'); 622 | events.removeAllListeners(); 623 | videoElement.onerror = null; 624 | videoElement.onended = null; 625 | videoElement.onpause = null; 626 | videoElement.onplay = null; 627 | videoElement.ontimeupdate = null; 628 | videoElement.ondurationchange = null; 629 | videoElement.onwaiting = null; 630 | videoElement.onseeking = null; 631 | videoElement.onseeked = null; 632 | videoElement.onstalled = null; 633 | videoElement.onplaying = null; 634 | videoElement.oncanplay = null; 635 | videoElement.canplaythrough = null; 636 | videoElement.onloadeddata = null; 637 | videoElement.onvolumechange = null; 638 | videoElement.onratechange = null; 639 | videoElement.textTracks.onchange = null; 640 | containerElement.removeChild(videoElement); 641 | containerElement.removeChild(styleElement); 642 | break; 643 | } 644 | } 645 | } 646 | 647 | this.on = function(eventName, listener) { 648 | if (destroyed) { 649 | throw new Error('Video is destroyed'); 650 | } 651 | 652 | events.on(eventName, listener); 653 | }; 654 | this.dispatch = function(action) { 655 | if (destroyed) { 656 | throw new Error('Video is destroyed'); 657 | } 658 | 659 | if (action) { 660 | action = deepFreeze(cloneDeep(action)); 661 | switch (action.type) { 662 | case 'observeProp': { 663 | observeProp(action.propName); 664 | return; 665 | } 666 | case 'setProp': { 667 | setProp(action.propName, action.propValue); 668 | return; 669 | } 670 | case 'command': { 671 | command(action.commandName, action.commandArgs); 672 | return; 673 | } 674 | } 675 | } 676 | 677 | throw new Error('Invalid action dispatched: ' + JSON.stringify(action)); 678 | }; 679 | } 680 | 681 | HTMLVideo.canPlayStream = function(stream) { 682 | if (!stream || (stream.behaviorHints && stream.behaviorHints.notWebReady)) { 683 | return Promise.resolve(false); 684 | } 685 | 686 | return getContentType(stream) 687 | .then(function(contentType) { 688 | var video = document.createElement('video'); 689 | return !!video.canPlayType(contentType) || (contentType === 'application/vnd.apple.mpegurl' && Hls.isSupported()); 690 | }) 691 | .catch(function() { 692 | return false; 693 | }); 694 | }; 695 | 696 | HTMLVideo.manifest = { 697 | name: 'HTMLVideo', 698 | external: false, 699 | props: ['stream', 'loaded', 'paused', 'time', 'duration', 'buffering', 'buffered', 'audioTracks', 'selectedAudioTrackId', 'subtitlesTracks', 'selectedSubtitlesTrackId', 'subtitlesOffset', 'subtitlesSize', 'subtitlesTextColor', 'subtitlesBackgroundColor', 'subtitlesOutlineColor', 'subtitlesOpacity', 'volume', 'muted', 'playbackSpeed'], 700 | commands: ['load', 'unload', 'destroy'], 701 | events: ['propValue', 'propChanged', 'ended', 'error', 'subtitlesTrackLoaded', 'audioTrackLoaded'] 702 | }; 703 | 704 | module.exports = HTMLVideo; 705 | --------------------------------------------------------------------------------