From 9cd57b5be7969f2f1fb1ff3f19b60d2175f540f9 Mon Sep 17 00:00:00 2001 From: Justin Ribeiro Date: Thu, 21 Nov 2019 11:57:12 -0800 Subject: [PATCH] feat(img): add webp placeholder support --- .eslintrc.js | 42 ++++++++++ .eslintrc.json | 42 ---------- lite-youtube.js | 191 +++++++++++++++++++++++++-------------------- package.json | 6 +- prettier.config.js | 11 +++ yarn.lock | 19 ++++- 6 files changed, 183 insertions(+), 128 deletions(-) create mode 100644 .eslintrc.js delete mode 100644 .eslintrc.json create mode 100644 prettier.config.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..bf4c2f4 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,42 @@ +module.exports = { + extends: ['eslint:recommended', 'google', 'prettier'], + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, + env: { + browser: true, + }, + plugins: ['html', 'lit'], + rules: { + 'max-len': [ + 'error', + { + ignoreTemplateLiterals: true, + ignoreStrings: true, + ignoreRegExpLiterals: true, + }, + ], + 'no-var': 'error', + 'require-jsdoc': 'off', + 'arrow-parens': 'off', + 'no-console': 'off', + 'new-cap': 'off', + 'brace-style': [2, '1tbs'], + 'no-loop-func': 'error', + 'no-await-in-loop': 'error', + 'no-useless-call': 'error', + 'padded-blocks': [ + 'error', + { + blocks: 'never', + classes: 'never', + switches: 'never', + }, + ], + 'space-in-parens': 'error', + }, + globals: { + customElements: false, + }, +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 1c80a10..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "extends": ["eslint:recommended", "google"], - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module" - }, - "env": { - "browser": true, - "es6": true, - "node": true - }, - "plugins": [ - "html" - ], - "rules": { - "max-len": ["error", { - "ignoreTemplateLiterals": true, - "ignoreStrings": true, - "ignoreRegExpLiterals": true - }], - "no-var": "error", - "require-jsdoc": "off", - "arrow-parens": "off", - "no-console": "off", - "new-cap": "off", - "indent": [2, 2], - "brace-style": [2, "1tbs"], - "no-loop-func": "error", - "no-await-in-loop": "error", - "no-useless-call": "error", - "padded-blocks": ["error", { - "blocks": "never", - "classes": "never", - "switches": "never" - }], - "space-in-parens": "error", - "singleQuote": true - }, - "globals": { - "customElements": false - } -} diff --git a/lite-youtube.js b/lite-youtube.js index 16f2de9..11b9d26 100644 --- a/lite-youtube.js +++ b/lite-youtube.js @@ -1,24 +1,24 @@ /** * - * The shadowDom version of Paul's concept: https://github.com/paulirish/lite-youtube-embed + * The shadowDom / Intersection Observer version of Paul's concept: + * https://github.com/paulirish/lite-youtube-embed * - * A lightweight youtube embed. Still should feel the same to the user, just MUCH faster to initialize and paint. + * A lightweight YouTube embed. Still should feel the same to the user, just + * MUCH faster to initialize and paint. * * Thx to these as the inspiration * https://storage.googleapis.com/amp-vs-non-amp/youtube-lazy.html * https://autoplay-youtube-player.glitch.me/ * - * Once built it, I also found these: - * https://github.com/ampproject/amphtml/blob/master/extensions/amp-youtube (👍👍) - * https://github.com/Daugilas/lazyYT - * https://github.com/vb/lazyframe + * Once built it, I also found these (👍👍): + * https://github.com/ampproject/amphtml/blob/master/extensions/amp-youtube + * https://github.com/Daugilas/lazyYT https://github.com/vb/lazyframe */ class LiteYTEmbed extends HTMLElement { constructor() { super(); - this.setupDom(); - this.__iframeLoaded = false; + this.__setupDom(); } static get observedAttributes() { @@ -26,17 +26,18 @@ class LiteYTEmbed extends HTMLElement { } connectedCallback() { - // On hover (or tap), warm up the TCP connections we're (likely) about to use. this.addEventListener('pointerover', LiteYTEmbed.warmConnections, { once: true, }); - // Once the user clicks, add the real iframe and drop our play button - // TODO: In the future we could be like amp-youtube and silently swap in the iframe during idle time - // We'd want to only do this for in-viewport or near-viewport ones: https://github.com/ampproject/amphtml/pull/5003 - this.addEventListener('click', e => this.addIframe()); + + this.addEventListener('click', e => this.__addIframe()); } - setupDom() { + /** + * Define our shadowDOM for the component + * @private + */ + __setupDom() { const shadowDom = this.attachShadow({mode: 'open'}); shadowDom.innerHTML = `
+ + + + +
`; this.__domRefFrame = this.shadowRoot.querySelector('#frame'); + this.__domRefImg = { + fallback: this.shadowRoot.querySelector('#fallbackPlaceholder'), + webp: this.shadowRoot.querySelector('#webpPlaceholder'), + jpeg: this.shadowRoot.querySelector('#jpegPlaceholder'), + }; this.__domRefPlayButton = this.shadowRoot.querySelector('.lty-playbtn'); } - setupComponent() { - // Gotta encode the untrusted value - // https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-2---attribute-escape-before-inserting-untrusted-data-into-html-common-attributes - this.videoId = encodeURIComponent(this.getAttribute( 'videoid' )); + /** + * Parse our attributes and fire up some placeholders + * @private + */ + __setupComponent() { + this.videoId = encodeURIComponent(this.getAttribute('videoid')); this.videoTitle = this.getAttribute('videotitle') || 'Video'; - this.videoPlay = this.getAttribute( 'videoplay' ) || 'Play'; + this.videoPlay = this.getAttribute('videoplay') || 'Play'; + this.autoLoad = this.getAttribute('autoload') === '' ? true : false; - // when set, this comes in as an empty string; when not set, undefined - this.autoLoad = this.getAttribute( 'autoload' ) === '' ? true : false; + this.__initImagePlaceholder(); - /** - * Lo, the youtube placeholder image! (aka the thumbnail, poster image, etc) - * There is much internet debate on the reliability of thumbnail URLs. Weak consensus is that you - * cannot rely on anything and have to use the YouTube Data API. - * - * amp-youtube also eschews using the API, so they just try sddefault with a hqdefault fallback: - * https://github.com/ampproject/amphtml/blob/6039a6317325a8589586e72e4f98c047dbcbf7ba/extensions/amp-youtube/0.1/amp-youtube.js#L498-L537 - * For now I'm gonna go with this confident (lol) assersion: https://stackoverflow.com/a/20542029, though I'll use `i.ytimg` to optimize for origin reuse. - * - * Worth noting that sddefault is _higher_ resolution than hqdefault. Naming is hard. ;) - * From my own testing, it appears that hqdefault is ALWAYS there sddefault is missing for ~10% of videos - * - * TODO: Do the sddefault->hqdefault fallback - * - When doing this, apply referrerpolicy (https://github.com/ampproject/amphtml/pull/3940) - * TODO: Consider using webp if supported, falling back to jpg - */ - this.posterUrl = `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`; - // Warm the connection for the poster image - LiteYTEmbed.addPrefetch('preload', this.posterUrl, 'image'); - // TODO: support dynamically setting the attribute via attributeChangedCallback - this.__domRefFrame.style.backgroundImage = `url("${this.posterUrl}")`; - this.__domRefPlayButton.setAttribute('aria-label', `${this.videoPlay}: ${this.videoTitle}`); - this.setAttribute( 'title', `${this.videoPlay}: ${this.videoTitle}` ); + this.__domRefPlayButton.setAttribute( + 'aria-label', + `${this.videoPlay}: ${this.videoTitle}`, + ); + this.setAttribute('title', `${this.videoPlay}: ${this.videoTitle}`); - // fire up the intersection observer if (this.autoLoad) { this.__initIntersectionObserver(); } @@ -172,7 +164,7 @@ class LiteYTEmbed extends HTMLElement { switch (name) { case 'videoid': { if (oldVal !== newVal) { - this.setupComponent(); + this.__setupComponent(); // if we have a previous iframe, remove it and the activated class if (this.__domRefFrame.classList.contains('lyt-activated')) { @@ -187,26 +179,64 @@ class LiteYTEmbed extends HTMLElement { } } + /** + * Inject the iframe into the component body + * @private + */ + __addIframe() { + const iframeHTML = ` +`; + this.__domRefFrame.insertAdjacentHTML('beforeend', iframeHTML); + this.__domRefFrame.classList.add('lyt-activated'); + this.__iframeLoaded = true; + } + + /** + * Setup the placeholder image for the component + * @private + */ + __initImagePlaceholder() { + // we don't know which image type to preload, so warm the connection + LiteYTEmbed.addPrefetch('preconnect', 'https://i.ytimg.com/'); + + const posterUrlWebp = `https://i.ytimg.com/vi_webp/${this.videoId}/hqdefault.webp`; + const posterUrlJpeg = `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`; + this.__domRefImg.webp.srcset = posterUrlWebp; + this.__domRefImg.jpeg.srcset = posterUrlJpeg; + this.__domRefImg.fallback.src = posterUrlJpeg; + this.__domRefImg.fallback.setAttribute( + 'aria-label', + `${this.videoPlay}: ${this.videoTitle}`, + ); + this.__domRefImg.fallback.setAttribute( + 'alt', + `${this.videoPlay}: ${this.videoTitle}`, + ); + } + /** * Setup the Intersection Observer to load the iframe when scrolled into view * @private */ - __initIntersectionObserver () { + __initIntersectionObserver() { if ( - ('IntersectionObserver' in window) && - ('IntersectionObserverEntry' in window) + 'IntersectionObserver' in window && + 'IntersectionObserverEntry' in window ) { const options = { root: null, rootMargin: '0px', - threshold: 0 - } + threshold: 0, + }; const observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting && !this.__iframeLoaded) { LiteYTEmbed.warmConnections(); - this.addIframe(); + this.__addIframe(); observer.unobserve(this); } }); @@ -231,21 +261,29 @@ class LiteYTEmbed extends HTMLElement { } /** - * Begin preconnecting to warm up the iframe load - * Since the embed's netwok requests load within its iframe, - * preload/prefetch'ing them outside the iframe will only cause double-downloads. - * So, the best we can do is warm up a few connections to origins that are in the critical path. + * Begin preconnecting to warm up the iframe load Since the embed's netwok + * requests load within its iframe, preload/prefetch'ing them outside the + * iframe will only cause double-downloads. So, the best we can do is warm up + * a few connections to origins that are in the critical path. * - * Maybe `` would work, but it's unsupported: http://crbug.com/593267 - * But TBH, I don't think it'll happen soon with Site Isolation and split caches adding serious complexity. + * Maybe `` would work, but it's unsupported: + * http://crbug.com/593267 But TBH, I don't think it'll happen soon with Site + * Isolation and split caches adding serious complexity. */ static warmConnections() { if (LiteYTEmbed.preconnected) return; - // The iframe document and most of its subresources come right off youtube.com + // Host that YT uses to serve JS needed by player, per amp-youtube + LiteYTEmbed.addPrefetch('preconnect', 'https://s.ytimg.com'); + + // The iframe document and most of its subresources come right off + // youtube.com LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube.com'); + // The botguard script is fetched off from google.com LiteYTEmbed.addPrefetch('preconnect', 'https://www.google.com'); - // Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling. + + // TODO: Not certain if these ad related domains are in the critical path. + // Could verify with domain-specific throttling. LiteYTEmbed.addPrefetch( 'preconnect', 'https://googleads.g.doubleclick.net', @@ -253,19 +291,6 @@ class LiteYTEmbed extends HTMLElement { LiteYTEmbed.addPrefetch('preconnect', 'https://static.doubleclick.net'); LiteYTEmbed.preconnected = true; } - - addIframe() { - // https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-2---attribute-escape-before-inserting-untrusted-data-into-html-common-attributes - const escapedVideoId = encodeURIComponent(this.videoId); - const iframeHTML = ` -`; - this.__domRefFrame.insertAdjacentHTML('beforeend', iframeHTML); - this.__domRefFrame.classList.add( 'lyt-activated' ); - this.__iframeLoaded = true; - } } // Register custom element customElements.define('lite-youtube', LiteYTEmbed); diff --git a/package.json b/package.json index cacb8ff..447b7dc 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "youtube" ], "devDependencies": { - "eslint": "^6.6.0", + "eslint": "^6.4.0", "eslint-config-google": "^0.14.0", - "eslint-plugin-html": "^6.0.0" + "eslint-config-prettier": "^6.3.0", + "eslint-plugin-html": "^6.0.0", + "prettier": "^1.18.2" } } diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..ae97cc9 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,11 @@ +module.exports = { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + trailingComma: 'all', + bracketSpacing: false, + jsxBracketSameLine: false, + arrowParens: 'avoid', +}; diff --git a/yarn.lock b/yarn.lock index 9f5d6aa..b1c8d79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -228,6 +228,13 @@ eslint-config-google@^0.14.0: resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a" integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw== +eslint-config-prettier@^6.3.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.7.0.tgz#9a876952e12df2b284adbd3440994bf1f39dfbb9" + integrity sha512-FamQVKM3jjUVwhG4hEMnbtsq7xOIDm+SY5iBPfR8gKsJoAB2IQnNF+bk1+8Fy44Nq7PPJaLvkRxILYdJWoguKQ== + dependencies: + get-stdin "^6.0.0" + eslint-plugin-html@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.0.tgz#28e5c3e71e6f612e07e73d7c215e469766628c13" @@ -255,7 +262,7 @@ eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== -eslint@^6.6.0: +eslint@^6.4.0: version "6.6.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.6.0.tgz#4a01a2fb48d32aacef5530ee9c5a78f11a8afd04" integrity sha512-PpEBq7b6qY/qrOmpYQ/jTMDYfuQMELR4g4WI1M/NaSDDD/bdcMb+dj4Hgks7p41kW2caXsPsEZAEAyAgjVVC0g== @@ -398,6 +405,11 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +get-stdin@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" + integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== + glob-parent@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" @@ -661,6 +673,11 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= +prettier@^1.18.2: + version "1.19.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" + integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== + progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"