├── webpack.mix.js ├── .idea ├── php.xml ├── codeStyles │ └── codeStyleConfig.xml ├── vcs.xml ├── modules.xml └── youtube-to-html5.iml ├── package.json ├── .gitignore ├── README.md ├── dist └── YouTubeToHtml5.js └── src └── YouTubeToHtml5.js /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | 3 | mix.js('src/YouTubeToHtml5.js', 'dist'); 4 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/youtube-to-html5.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@thelevicole/youtube-to-html5-loader", 3 | "version": "5.0.0", 4 | "description": "A javascript library to load YoutTube videos as HTML5 emebed elements.", 5 | "main": "dist/YouTubeToHtml5.js", 6 | "scripts": { 7 | "dev": "npm run development", 8 | "development": "mix", 9 | "watch": "mix watch", 10 | "watch-poll": "mix watch -- --watch-options-poll=1000", 11 | "hot": "mix watch --hot", 12 | "prod": "npm run production", 13 | "production": "mix --production" 14 | }, 15 | "keywords": [ 16 | "youtube", 17 | "html5-video", 18 | "youtube-api", 19 | "video" 20 | ], 21 | "devDependencies": { 22 | "laravel-mix": "^6.0.49" 23 | }, 24 | "browserslist": [ 25 | "last 3 version", 26 | "> 1%" 27 | ], 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/thelevicole/youtube-to-html5-loader.git" 31 | }, 32 | "author": { 33 | "name": "Levi Cole", 34 | "email": "dev@thelevicole.com" 35 | }, 36 | "license": "ISC", 37 | "bugs": { 38 | "url": "https://github.com/thelevicole/youtube-to-html5-loader/issues" 39 | }, 40 | "homepage": "https://thelevicole.com/youtube-to-html5-loader/" 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/workspace.xml 2 | test/ 3 | mix-manifest.json 4 | 5 | # Created by https://www.toptal.com/developers/gitignore/api/node,macos,linux,windows 6 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,linux,windows 7 | 8 | ### Linux ### 9 | *~ 10 | 11 | # temporary files which can be created if a process still has a handle open of a deleted file 12 | .fuse_hidden* 13 | 14 | # KDE directory preferences 15 | .directory 16 | 17 | # Linux trash folder which might appear on any partition or disk 18 | .Trash-* 19 | 20 | # .nfs files are created when an open file is removed but is still being accessed 21 | .nfs* 22 | 23 | ### macOS ### 24 | # General 25 | .DS_Store 26 | .AppleDouble 27 | .LSOverride 28 | 29 | # Icon must end with two \r 30 | Icon 31 | 32 | 33 | # Thumbnails 34 | ._* 35 | 36 | # Files that might appear in the root of a volume 37 | .DocumentRevisions-V100 38 | .fseventsd 39 | .Spotlight-V100 40 | .TemporaryItems 41 | .Trashes 42 | .VolumeIcon.icns 43 | .com.apple.timemachine.donotpresent 44 | 45 | # Directories potentially created on remote AFP share 46 | .AppleDB 47 | .AppleDesktop 48 | Network Trash Folder 49 | Temporary Items 50 | .apdisk 51 | 52 | ### Node ### 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | lerna-debug.log* 60 | 61 | # Diagnostic reports (https://nodejs.org/api/report.html) 62 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 63 | 64 | # Runtime data 65 | pids 66 | *.pid 67 | *.seed 68 | *.pid.lock 69 | 70 | # Directory for instrumented libs generated by jscoverage/JSCover 71 | lib-cov 72 | 73 | # Coverage directory used by tools like istanbul 74 | coverage 75 | *.lcov 76 | 77 | # nyc test coverage 78 | .nyc_output 79 | 80 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 81 | .grunt 82 | 83 | # Bower dependency directory (https://bower.io/) 84 | bower_components 85 | 86 | # node-waf configuration 87 | .lock-wscript 88 | 89 | # Compiled binary addons (https://nodejs.org/api/addons.html) 90 | build/Release 91 | 92 | # Dependency directories 93 | node_modules/ 94 | jspm_packages/ 95 | 96 | # TypeScript v1 declaration files 97 | typings/ 98 | 99 | # TypeScript cache 100 | *.tsbuildinfo 101 | 102 | # Optional npm cache directory 103 | .npm 104 | 105 | # Optional eslint cache 106 | .eslintcache 107 | 108 | # Microbundle cache 109 | .rpt2_cache/ 110 | .rts2_cache_cjs/ 111 | .rts2_cache_es/ 112 | .rts2_cache_umd/ 113 | 114 | # Optional REPL history 115 | .node_repl_history 116 | 117 | # Output of 'npm pack' 118 | *.tgz 119 | 120 | # Yarn Integrity file 121 | .yarn-integrity 122 | 123 | # dotenv environment variables file 124 | .env 125 | .env.test 126 | .env*.local 127 | 128 | # parcel-bundler cache (https://parceljs.org/) 129 | .cache 130 | .parcel-cache 131 | 132 | # Next.js build output 133 | .next 134 | 135 | # Nuxt.js build / generate output 136 | .nuxt 137 | 138 | # Gatsby files 139 | .cache/ 140 | # Comment in the public line in if your project uses Gatsby and not Next.js 141 | # https://nextjs.org/blog/next-9-1#public-directory-support 142 | # public 143 | 144 | # vuepress build output 145 | .vuepress/dist 146 | 147 | # Serverless directories 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | .fusebox/ 152 | 153 | # DynamoDB Local files 154 | .dynamodb/ 155 | 156 | # TernJS port file 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | .vscode-test 161 | 162 | ### Windows ### 163 | # Windows thumbnail cache files 164 | Thumbs.db 165 | Thumbs.db:encryptable 166 | ehthumbs.db 167 | ehthumbs_vista.db 168 | 169 | # Dump file 170 | *.stackdump 171 | 172 | # Folder config file 173 | [Dd]esktop.ini 174 | 175 | # Recycle Bin used on file shares 176 | $RECYCLE.BIN/ 177 | 178 | # Windows Installer files 179 | *.cab 180 | *.msi 181 | *.msix 182 | *.msm 183 | *.msp 184 | 185 | # Windows shortcuts 186 | *.lnk 187 | 188 | # End of https://www.toptal.com/developers/gitignore/api/node,macos,linux,windows 189 | .idea/workspace.xml 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Load YoutTube videos as HTML5 emebed element 3 | 4 | [![](https://data.jsdelivr.com/v1/package/npm/@thelevicole/youtube-to-html5-loader/badge)](https://www.jsdelivr.com/package/npm/@thelevicole/youtube-to-html5-loader) 5 | [![Latest Stable Version](https://img.shields.io/npm/v/@thelevicole/youtube-to-html5-loader)](https://www.npmjs.com/package/@thelevicole/youtube-to-html5-loader) 6 | [![Total Downloads](https://img.shields.io/npm/dt/@thelevicole/youtube-to-html5-loader)](https://www.npmjs.com/package/@thelevicole/youtube-to-html5-loader) 7 | 8 | ## Get started 9 | 10 | ### Load library 11 | First you need to include the library in your project, this can be achieved via NPM or jsDeliver. 12 | 13 | #### NPM 14 | ``` 15 | npm i @thelevicole/youtube-to-html5-loader 16 | ``` 17 | ```javascript 18 | import YouTubeToHtml5 from '@thelevicole/youtube-to-html5-loader' 19 | ``` 20 | 21 | #### jsDeliver 22 | ```html 23 | 24 | ``` 25 | 26 | ### Initiating 27 | First setup your HTML something like: 28 | ```html 29 | 30 | ``` 31 | And then simply initiate the library with: 32 | ```javascript 33 | new YouTubeToHtml5(); 34 | ``` 35 | 36 | ### Options 37 | There are a number of options that can be passed to the constructor these are: 38 | | Option | Description | Type | Default | 39 | |--|--|--|--| 40 | | `endpoint` | This is the API url thats used for retrieving data. More information to come. | `string` | `https://yt2html5.com/?id=` | 41 | | `selector` | The DOM selector used for finding video elements. | `string` | `video[data-yt2html5]` | 42 | | `attribute` | This is the attribute where your YouTube id/url is stored on the element. | `string` | `data-yt2html5` | 43 | | `formats` | Filter the API results by specific formats. For example `[ '1080p', '720p' ]` will only allow 1080p and 720p formats. An asterix will allow all streaming formats. | `string|array` | `*` | 44 | | `autoload` | Whether or not to load all videos on library init. | `boolean` | `true` | 45 | | `withAudio` | Whether or not to only load streams with audio. | `boolean` | `true` | 46 | | `withVideo` | Whether or not to only load streams with video. | `boolean` | `true` | 47 | 48 | ### Changing the API endpoint and custom server 49 | This package uses a man-in-the-middle server (yt2html.com) to handle the API requests. This can cause issues as YouTube often blocks the host causing the library to not work. A solution to this is to host your own man-in-the-middle server and change the libraries API endpoint. 50 | 51 | Simply modify the libraries global endpoint with the below snippet. Make sure to place before any `YouTubeToHtml5()` initiations. 52 | ```javascript 53 | YouTubeToHtml5.defaultOptions.endpoint = 'http://myserver.com/?id='; 54 | ``` 55 | 56 | The server source can be found here: [thelevicole/youtube-to-html5-server](https://github.com/thelevicole/youtube-to-html5-server) 57 | 58 | ### Hooks 59 | The library has a hook mechanism for filters and actions. If you've worked with WordPress before you'll be familiar with this concept. 60 | 61 | > Note: You'll need to disable auto loading when using any hooks. First create an instance, then bind your hooks and finally call the `.load()` method. 62 | 63 | #### Filters 64 | Modify and return values. 65 | ##### Request URL 66 | You might want to modify the request URL on each element load. You can do this with the `request.url` filter. For example: 67 | ```javascript 68 | const controller = new YouTubeToHtml5({ 69 | autoload: false 70 | }); 71 | 72 | controller.addFilter('request.url', function(url) { 73 | return `${url}&cache_bust=${(new Date()).getTime()}`; 74 | }); 75 | 76 | controller.load(); 77 | ``` 78 | 79 | #### Actions 80 | Run code every time the action is called. 81 | ##### Before each load 82 | ```javascript 83 | const controller = new YouTubeToHtml5({ 84 | autoload: false 85 | }); 86 | 87 | controller.addAction('load.before', function(element, data) { 88 | element.classList.add('is-loading'); 89 | }); 90 | 91 | controller.load(); 92 | ``` 93 | ##### After each load 94 | ```javascript 95 | const controller = new YouTubeToHtml5({ 96 | autoload: false 97 | }); 98 | 99 | controller.addAction('load.after', function(element, data) { 100 | element.classList.remove('is-loading'); 101 | }); 102 | 103 | controller.load(); 104 | ``` 105 | ##### After a successful load 106 | ```javascript 107 | const controller = new YouTubeToHtml5({ 108 | autoload: false 109 | }); 110 | 111 | controller.addAction('load.success', function(element, data) { 112 | element.classList.addClass('is-playable'); 113 | }); 114 | 115 | controller.load(); 116 | ``` 117 | ##### After a failed load 118 | ```javascript 119 | const controller = new YouTubeToHtml5({ 120 | autoload: false 121 | }); 122 | 123 | controller.addAction('load.failed', function(element, data) { 124 | element.classList.add('is-unplayable'); 125 | }); 126 | 127 | controller.load(); 128 | ``` 129 | 130 | ### jQuery 131 | The library now includes a simply jQuery plugin which can be used like so... 132 | 133 | ```js 134 | $('video[data-yt2html5]').youtubeToHtml5(); 135 | ``` 136 | 137 | The `.youtubeToHtml5()` plugin returns the `YouTubeToHtml5` class instance so adding hooks etc is just as described above... 138 | 139 | ```js 140 | const controller = $('video[data-yt2html5]').youtubeToHtml5({ 141 | autoload: false 142 | }); 143 | 144 | controller.addAction('load.failed', function(element, data) { 145 | element.classList.add('is-unplayable'); 146 | }); 147 | 148 | controller.load(); 149 | ``` 150 | 151 | 152 | ## Accepted URL patterns 153 | Below is a list of varying YouTube url patterns, which include http/s and www/non-www. 154 | 155 | ``` 156 | youtube.com/watch?v=ScMzIvxBSi4 157 | youtube.com/watch?vi=ScMzIvxBSi4 158 | youtube.com/v/ScMzIvxBSi4 159 | youtube.com/vi/ScMzIvxBSi4 160 | youtube.com/?v=ScMzIvxBSi4 161 | youtube.com/?vi=ScMzIvxBSi4 162 | youtu.be/ScMzIvxBSi4 163 | youtube.com/embed/ScMzIvxBSi4 164 | youtube.com/v/ScMzIvxBSi4 165 | youtube.com/watch?v=ScMzIvxBSi4&wtv=wtv 166 | youtube.com/watch?dev=inprogress&v=ScMzIvxBSi4&feature=related 167 | m.youtube.com/watch?v=ScMzIvxBSi4 168 | youtube-nocookie.com/embed/ScMzIvxBSi4 169 | ``` 170 | -------------------------------------------------------------------------------- /dist/YouTubeToHtml5.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";function t(t,n){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!e){if(Array.isArray(t)||(e=function(t,n){if(!t)return;if("string"==typeof t)return o(t,n);var e=Object.prototype.toString.call(t).slice(8,-1);"Object"===e&&t.constructor&&(e=t.constructor.name);if("Map"===e||"Set"===e)return Array.from(t);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return o(t,n)}(t))||n&&t&&"number"==typeof t.length){e&&(t=e);var r=0,i=function(){};return{s:i,n:function(){return r>=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,u=!0,l=!1;return{s:function(){e=e.call(t)},n:function(){var t=e.next();return u=t.done,t},e:function(t){l=!0,a=t},f:function(){try{u||null==e.return||e.return()}finally{if(l)throw a}}}}function o(t,o){(null==o||o>t.length)&&(o=t.length);for(var n=0,e=new Array(o);n1&&void 0!==arguments[1]?arguments[1]:null;!o&&t in this.class.defaultOptions&&(o=this.class.defaultOptions[t]);var n=t in this.options?this.options[t]:o;return n=this.applyFilters("option",n,t),n=this.applyFilters("option.".concat(t),n)}},{key:"getHooks",value:function(t,o){var n=[];if(t in this.class.globalHooks){var e=this.class.globalHooks[t];e=(e=e.filter((function(t){return t.name===o}))).sort((function(t,o){return t.priority-o.priority})),n=n.concat(e)}if(t in this.hooks){var r=this.hooks[t];r=(r=r.filter((function(t){return t.name===o}))).sort((function(t,o){return t.priority-o.priority})),n=n.concat(r)}return n}},{key:"addHook",value:function(t,o){t in this.class.globalHooks||(this.class.globalHooks[t]=[]),t in this.hooks||(this.hooks[t]=[]),"global"in o&&o.global?this.class.globalHooks[t].push(o):this.hooks[t].push(o)}},{key:"addAction",value:function(t,o){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:10,e=arguments.length>3&&void 0!==arguments[3]&&arguments[3];this.addHook("actions",{name:t,callback:o,priority:n,global:e})}},{key:"doAction",value:function(t){for(var o=this,n=arguments.length,e=new Array(n>1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:10,e=arguments.length>3&&void 0!==arguments[3]&&arguments[3];this.addHook("filters",{name:t,callback:o,priority:n,global:e})}},{key:"applyFilters",value:function(t,o){for(var n=this,e=arguments.length,r=new Array(e>2?e-2:0),i=2;i0)},function(t){return+(t.hasVideo&&t.hasAudio)},function(t){return+t.hasVideo},function(t){return parseInt(t.format)||0},function(t){return t._raw.bitrate||0},function(t){return t._raw.audioBitrate||0},function(t){return["mp4v","avc1","Sorenson H.283","MPEG-4 Visual","VP8","VP9","H.264"].findIndex((function(o){return t._raw.codecs&&t._raw.codecs.includes(o)}))},function(t){return["mp4a","mp3","vorbis","aac","opus","flac"].findIndex((function(o){return t._raw.codecs&&t._raw.codecs.includes(o)}))}])})),this.getOption("withAudio")&&(e=e.filter((function(t){return t.hasAudio}))),this.getOption("withVideo")&&(e=e.filter((function(t){return t.hasVideo})));var r=this.getOption("formats");return"*"!==r&&(e=e.filter((function(t){return Array.from(r).includes(t.format)}))),e}},{key:"canPlayType",value:function(t){var o,n=(o=/^audio/i.test(t)?document.createElement("audio"):document.createElement("video"))&&"function"==typeof o.canPlayType?o.canPlayType(t):"unknown";return n||"no"}},{key:"load",value:function(){var t=this,o=this.getElements(this.getOption("selector"));o&&o.length&&o.forEach((function(o){return t.loadSingle(o)}))}},{key:"loadSingle",value:function(t){var o=this,n=this.getOption("attribute");if(t.getAttribute(n)){var e=this.urlToId(t.getAttribute(n)),r=this.requestUrl(e);this.doAction("load.before",t),fetch(r).then((function(n){n.json().then((function(n){return o.doAction("load.success",t,n)}))})).catch((function(n){n.json().then((function(n){return o.doAction("load.failed",t,n)}))})).finally((function(){o.doAction("load.after",t)}))}}}],u=[{key:"_actionLoadSuccess",value:function(t,o,n){var e=t.getStreamData(n),r=(e=e.filter((function(t){return t.type===o.tagName.toLowerCase()}))).shift();r&&(o.src=r.url)}},{key:"_actionLoadFailed",value:function(t,o,n){console.warn("".concat(t.class," was unable to load video."))}}],a&&e(i.prototype,a),u&&e(i,u),Object.defineProperty(i,"prototype",{writable:!1}),o}();r(i,"globalHooks",{}),r(i,"defaultOptions",{endpoint:"https://yt2html5.com/?id=",selector:"video[data-yt2html5]",attribute:"data-yt2html5",formats:"*",autoload:!0,withAudio:!1,withVideo:!0}),window.YouTubeToHtml5=i,"undefined"!=typeof jQuery&&(jQuery.fn.youtubeToHtml5=function(){var t=this,o=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n="autoload"in o?o.autoload:i.defaultOptions.autoload;o.autoload=!1;var e=new i(o);return e.addFilter("elements",(function(){return Array.from(t)})),n&&e.load(),e})}(); -------------------------------------------------------------------------------- /src/YouTubeToHtml5.js: -------------------------------------------------------------------------------- 1 | class YouTubeToHtml5 { 2 | 3 | static globalHooks = {}; 4 | 5 | static defaultOptions = { 6 | endpoint: 'https://yt2html5.com/?id=', 7 | selector: 'video[data-yt2html5]', 8 | attribute: 'data-yt2html5', 9 | formats: '*', // Accepts an array of formats e.g. [ '1080p', '720p', '320p' ] or a single format '1080p'. Asterix for all. 10 | autoload: true, 11 | withAudio: false, 12 | withVideo: true 13 | } 14 | 15 | class = YouTubeToHtml5; 16 | options = {}; 17 | hooks = {}; 18 | 19 | /** 20 | * @param {{ 21 | * endpoint: string, 22 | * selector: string, 23 | * attribute: string, 24 | * formats: string|array, 25 | * autoload: boolean, 26 | * withAudio: boolean, 27 | * withVideo: boolean 28 | * }} options 29 | */ 30 | constructor(options) { 31 | this.options = options; 32 | 33 | // Add default load actions. 34 | this.addAction('load.success', this.class._actionLoadSuccess, 0); 35 | this.addAction('load.failed', this.class._actionLoadFailed, 0); 36 | 37 | if (this.getOption('autoload')) { 38 | this.load(); 39 | } 40 | } 41 | 42 | /** 43 | * Get a user or default option. 44 | * @param {string} name 45 | * @param defaultValue 46 | * @returns {*} 47 | */ 48 | getOption(name, defaultValue = null) { 49 | if (!defaultValue && name in this.class.defaultOptions) { 50 | defaultValue = this.class.defaultOptions[name]; 51 | } 52 | 53 | var value = name in this.options ? this.options[name] : defaultValue; 54 | 55 | /** 56 | * Apply value filters to all regardless of option name. 57 | * @example instance.addFilter('option', function(value, name) { return value + 500; }); 58 | */ 59 | value = this.applyFilters(`option`, value, name ); 60 | 61 | /** 62 | * Apply value filters to option named only. 63 | * @example instance.addFilter('setting.delay', function(value) { return value + 500; }); 64 | */ 65 | value = this.applyFilters(`option.${name}`, value ); 66 | 67 | return value; 68 | } 69 | 70 | /** 71 | * Get hooks by type and name. Ordered by priority. 72 | * @param {string} type 73 | * @param {string} name 74 | * @returns {array} 75 | */ 76 | getHooks(type, name) { 77 | let hooks = []; 78 | 79 | if (type in this.class.globalHooks) { 80 | let globalHooks = this.class.globalHooks[type]; 81 | globalHooks = globalHooks.filter(el => el.name === name); 82 | globalHooks = globalHooks.sort((a, b) => a.priority - b.priority); 83 | hooks = hooks.concat(globalHooks); 84 | } 85 | 86 | if (type in this.hooks) { 87 | let localHooks = this.hooks[ type ]; 88 | localHooks = localHooks.filter(el => el.name === name); 89 | localHooks = localHooks.sort((a, b) => a.priority - b.priority); 90 | hooks = hooks.concat(localHooks); 91 | } 92 | 93 | return hooks; 94 | } 95 | 96 | /** 97 | * Register a hook. 98 | * @param {string} type 99 | * @param {object} hookMeta 100 | */ 101 | addHook(type, hookMeta) { 102 | 103 | // Create new global hook type array. 104 | if (!(type in this.class.globalHooks)) { 105 | this.class.globalHooks[type] = []; 106 | } 107 | 108 | // Create new local hook type array. 109 | if (!(type in this.hooks)) { 110 | this.hooks[type] = []; 111 | } 112 | 113 | // Add to global. 114 | if ('global' in hookMeta && hookMeta.global) { 115 | this.class.globalHooks[type].push(hookMeta); 116 | } 117 | 118 | // Else, add to local. 119 | else { 120 | this.hooks[type].push(hookMeta); 121 | } 122 | 123 | } 124 | 125 | /** 126 | * Add action callback. 127 | * @param {string} action Name of action to trigger callback on. 128 | * @param {function} callback 129 | * @param {number} priority 130 | * @param {boolean} global True if this action should apply to all instances. 131 | */ 132 | addAction(action, callback, priority = 10, global = false) { 133 | this.addHook('actions', { 134 | name: action, 135 | callback: callback, 136 | priority: priority, 137 | global: global 138 | }); 139 | } 140 | 141 | /** 142 | * Trigger an action. 143 | * @param {string} name Name of action to run. 144 | * @param {*} args Arguments passed to the callback function. 145 | */ 146 | doAction(name, ...args) { 147 | this.getHooks('actions', name).forEach(hook => { 148 | hook.callback.apply(this, args); 149 | }); 150 | } 151 | 152 | /** 153 | * Register filter. 154 | * @param {string} filter Name of filter to trigger callback on. 155 | * @param {function} callback 156 | * @param {number} priority 157 | * @param {boolean} global True if this action should apply to all instances. 158 | */ 159 | addFilter(filter, callback, priority = 10, global = false) { 160 | this.addHook('filters', { 161 | name: filter, 162 | callback: callback, 163 | priority: priority, 164 | global: global 165 | }); 166 | } 167 | 168 | /** 169 | * Apply all named filters to a value. 170 | * @param {string} name Name of action to run. 171 | * @param {*} value The value to be mutated. 172 | * @param {*} args Arguments passed to the callback function. 173 | * @returns {*} 174 | */ 175 | applyFilters(name, value, ...args) { 176 | this.getHooks('filters', name).forEach(hook => { 177 | value = hook.callback.apply(this, [value].concat(args)); 178 | }); 179 | return value; 180 | } 181 | 182 | /** 183 | * Extract the Youtube ID from a URL. Returns full value if no matches. 184 | * @param {string} url 185 | * @returns {string} 186 | */ 187 | urlToId(url) { 188 | const regex = /^(?:http(?:s)?:\/\/)?(?:www\.)?(?:m\.)?(?:youtu\.be\/|(?:(?:youtube-nocookie\.com\/|youtube\.com\/)(?:(?:watch)?\?(?:.*&)?v(?:i)?=|(?:embed|v|vi|user)\/)))([a-zA-Z0-9\-_]*)/; 189 | const matches = url.match(regex); 190 | return Array.isArray(matches) && matches[1] ? matches[1] : url; 191 | } 192 | 193 | /** 194 | * Get list of elements found with the selector. 195 | * @param {NodeList|HTMLCollection|string} selector 196 | * @returns {array} 197 | */ 198 | getElements(selector) { 199 | var elements = null; 200 | 201 | if (selector) { 202 | if (NodeList.prototype.isPrototypeOf(selector) || HTMLCollection.prototype.isPrototypeOf(selector)) { 203 | elements = selector; 204 | } else if (typeof selector === 'object' && 'nodeType' in selector && selector.nodeType) { 205 | elements = [selector]; 206 | } else { 207 | elements = document.querySelectorAll(this.getOption('selector')); 208 | } 209 | } 210 | 211 | elements = Array.from(elements || ''); 212 | 213 | return this.applyFilters('elements', elements); 214 | } 215 | 216 | /** 217 | * Build API url from video id. 218 | * @param {string} videoId 219 | * @returns {string} 220 | */ 221 | requestUrl(videoId) { 222 | const endpoint = this.getOption('endpoint'); 223 | const url = endpoint + videoId; 224 | return this.applyFilters('request.url', url, endpoint, videoId); 225 | } 226 | 227 | /** 228 | * Sort formats by a list of functions. 229 | * 230 | * @param {object} a 231 | * @param {object} b 232 | * @param {function[]} processors 233 | * @returns {number} 234 | */ 235 | bulkSortBy(a, b, processors) { 236 | let result = 0; 237 | 238 | for (let fn of processors) { 239 | const diff = fn(b) - fn(a); 240 | result += diff; 241 | } 242 | 243 | return result; 244 | } 245 | 246 | /** 247 | * Get stream data from API response. 248 | * @param {object} response 249 | * @returns {array} 250 | */ 251 | getStreamData(response) { 252 | const data = response?.data || {}; 253 | 254 | let streams = []; 255 | 256 | // Build streams array 257 | Array.from(data.formats || '').forEach(stream => { 258 | let thisData = { 259 | _raw: stream, 260 | itag: stream.itag, 261 | url: stream.url, 262 | format: stream.qualityLabel, 263 | type: 'unknown', 264 | mime: 'unknown', 265 | hasAudio: stream.hasAudio, 266 | hasVideo: stream.hasVideo, 267 | browserSupport: 'unknown' 268 | }; 269 | 270 | if (!thisData.format) { 271 | // Add audio format fallback 272 | if (thisData.hasAudio && !thisData.hasVideo) { 273 | thisData.format = `${stream.audioBitrate}kbps`; 274 | } 275 | } 276 | 277 | // Extract stream data from mimetype. 278 | if ('mimeType' in stream) { 279 | 280 | const mimeParts = stream.mimeType.match(/^(audio|video)(?:\/([^;]+);)?/i); 281 | 282 | // Set media type (video, audo) 283 | if (mimeParts[1]) { 284 | thisData.type = mimeParts[ 1 ]; 285 | } 286 | 287 | // Set media mime (mp4, ogg...etc) 288 | if (mimeParts[2]) { 289 | thisData.mime = mimeParts[2]; 290 | } 291 | 292 | // Set browser support rating 293 | thisData.browserSupport = this.canPlayType(`${thisData.type}/${thisData.mime}`); 294 | } 295 | 296 | streams.push(thisData); 297 | }); 298 | 299 | // Sort streams by playability and quality 300 | streams.sort((a, b) => { 301 | return this.bulkSortBy(a, b, [ 302 | format => { 303 | return { 304 | 'unknown': -1, 305 | 'no': -1, 306 | 'maybe': 0, 307 | 'probably': 1 308 | }[format.browserSupport]; 309 | }, 310 | format => +!!format._raw.isHLS, 311 | format => +!!format._raw.isDashMPD, 312 | format => +(format._raw.contentLength > 0), 313 | format => +(format.hasVideo && format.hasAudio), 314 | format => +format.hasVideo, 315 | format => parseInt(format.format) || 0, 316 | format => format._raw.bitrate || 0, 317 | format => format._raw.audioBitrate || 0, 318 | format => [ 319 | 'mp4v', 320 | 'avc1', 321 | 'Sorenson H.283', 322 | 'MPEG-4 Visual', 323 | 'VP8', 324 | 'VP9', 325 | 'H.264', 326 | ].findIndex(encoding => format._raw.codecs && format._raw.codecs.includes(encoding)), 327 | format => [ 328 | 'mp4a', 329 | 'mp3', 330 | 'vorbis', 331 | 'aac', 332 | 'opus', 333 | 'flac', 334 | ].findIndex(encoding => format._raw.codecs && format._raw.codecs.includes(encoding)) 335 | ]); 336 | }); 337 | 338 | // Only return streams with audio 339 | if (this.getOption('withAudio')) { 340 | streams = streams.filter(item => item.hasAudio); 341 | } 342 | 343 | // Only return streams with video 344 | if (this.getOption('withVideo')) { 345 | streams = streams.filter(item => item.hasVideo); 346 | } 347 | 348 | const allowedFormats = this.getOption('formats'); 349 | 350 | // Filter streams further by allowed formats. 351 | if (allowedFormats !== '*') { 352 | streams = streams.filter(item => Array.from(allowedFormats).includes(item.format)); 353 | } 354 | 355 | return streams; 356 | } 357 | 358 | /** 359 | * Check if a given mime type can be played by the browser. 360 | * @link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canPlayType 361 | * @param {string} type For example "video/mp4" 362 | * @returns {CanPlayTypeResult|string} probably, maybe, no, unkown 363 | */ 364 | canPlayType(type) { 365 | 366 | var phantomEl; 367 | 368 | if (/^audio/i.test(type)) { 369 | phantomEl = document.createElement('audio'); 370 | } else { 371 | phantomEl = document.createElement('video'); 372 | } 373 | 374 | const value = phantomEl && typeof phantomEl.canPlayType === 'function' ? phantomEl.canPlayType(type) : 'unknown'; 375 | 376 | return value ? value : 'no'; 377 | } 378 | 379 | /** 380 | * Run our full process. Loops through each element matching the selector. 381 | */ 382 | load() { 383 | const elements = this.getElements(this.getOption('selector')); 384 | 385 | if (elements && elements.length) { 386 | elements.forEach(element => this.loadSingle(element) ); 387 | } 388 | } 389 | 390 | /** 391 | * Process a single element. 392 | * @param {Element} element 393 | */ 394 | loadSingle(element) { 395 | 396 | /** 397 | * Attribute name for grabbing YouTube identifier/url. 398 | * 399 | * @type {string} 400 | */ 401 | const attribute = this.getOption('attribute'); 402 | 403 | // Check if element has attribute value 404 | if (element.getAttribute(attribute)) { 405 | 406 | // Extract video id from attribute value. 407 | const videoId = this.urlToId(element.getAttribute(attribute)); 408 | 409 | // Build request url. 410 | const requestUrl = this.requestUrl(videoId); 411 | 412 | this.doAction('load.before', element); 413 | 414 | fetch(requestUrl).then(response => { 415 | response.json().then(json => this.doAction('load.success', element, json)); 416 | }).catch(response => { 417 | response.json().then(json => this.doAction('load.failed', element, json)); 418 | }).finally(() => { 419 | this.doAction('load.after', element) 420 | }); 421 | } 422 | } 423 | 424 | /** 425 | * Parse raw YouTube response into usable data. 426 | * @param {YouTubeToHtml5} context 427 | * @param {Element} element 428 | * @param {object} response 429 | */ 430 | static _actionLoadSuccess(context, element, response) { 431 | 432 | let streams = context.getStreamData(response); 433 | 434 | // Limit to element tag name (video/audio) 435 | streams = streams.filter(item => item.type === element.tagName.toLowerCase()); 436 | 437 | // Get the top priority stream 438 | const stream = streams.shift(); 439 | 440 | if (stream) { 441 | element.src = stream.url; 442 | } 443 | } 444 | 445 | /** 446 | * Handle failed response. 447 | * @param {YouTubeToHtml5} context 448 | * @param {Element} element 449 | * @param {object} response 450 | */ 451 | static _actionLoadFailed(context, element, response) { 452 | console.warn(`${context.class} was unable to load video.`); 453 | } 454 | 455 | } 456 | 457 | /** 458 | * Add class to the window's global scope. 459 | * 460 | * @type {YouTubeToHtml5} 461 | */ 462 | window.YouTubeToHtml5 = YouTubeToHtml5; 463 | 464 | /** 465 | * Add jQuery plugin if exists. 466 | */ 467 | if (typeof jQuery !== 'undefined') { 468 | (function($) { 469 | /** 470 | * 471 | * @param {{ 472 | * endpoint: string, 473 | * formats: string|array, 474 | * autoload: boolean, 475 | * withAudio: boolean, 476 | * withVideo: boolean 477 | * }} options 478 | * @return {YouTubeToHtml5} 479 | */ 480 | $.fn.youtubeToHtml5 = function(options = {}) { 481 | 482 | // Cache user default autoload option. 483 | const isAutoload = 'autoload' in options ? options.autoload : YouTubeToHtml5.defaultOptions.autoload; 484 | 485 | // For jQuery we will need to make some modifications before we process loading. 486 | options.autoload = false; 487 | 488 | // Create new instance. 489 | const controller = new YouTubeToHtml5(options); 490 | 491 | // Overide core elements with jQuery selected elements. 492 | controller.addFilter('elements', () => Array.from(this)); 493 | 494 | // Now we can autoload. 495 | if (isAutoload) { 496 | controller.load(); 497 | } 498 | 499 | // Return controller instance. 500 | return controller; 501 | } 502 | 503 | })(jQuery); 504 | } 505 | 506 | /** 507 | * Export module. 508 | */ 509 | export default YouTubeToHtml5; 510 | --------------------------------------------------------------------------------