├── 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 |
4 |
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://www.jsdelivr.com/package/npm/@thelevicole/youtube-to-html5-loader)
5 | [](https://www.npmjs.com/package/@thelevicole/youtube-to-html5-loader)
6 | [](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 |
--------------------------------------------------------------------------------