|
| 1 | +/** |
| 2 | + * A lightweight youtube embed. Still should feel the same to the user, just MUCH faster to initialize and paint. |
| 3 | + * |
| 4 | + * Thx to these as the inspiration |
| 5 | + * https://storage.googleapis.com/amp-vs-non-amp/youtube-lazy.html |
| 6 | + * https://autoplay-youtube-player.glitch.me/ |
| 7 | + * |
| 8 | + * Once built it, I also found these: |
| 9 | + * https://github.com/ampproject/amphtml/blob/master/extensions/amp-youtube (👍👍) |
| 10 | + * https://github.com/Daugilas/lazyYT |
| 11 | + * https://github.com/vb/lazyframe |
| 12 | + */ |
| 13 | +class LiteYTEmbed extends HTMLElement { |
| 14 | + connectedCallback() { |
| 15 | + this.videoId = this.getAttribute('videoid'); |
| 16 | + |
| 17 | + let playBtnEl = this.querySelector('.lty-playbtn'); |
| 18 | + // A label for the button takes priority over a [playlabel] attribute on the custom-element |
| 19 | + this.playLabel = (playBtnEl && playBtnEl.textContent.trim()) || this.getAttribute('playlabel') || 'Play'; |
| 20 | + |
| 21 | + this.dataset.title = this.getAttribute('title') || ""; |
| 22 | + |
| 23 | + /** |
| 24 | + * Lo, the youtube poster image! (aka the thumbnail, image placeholder, etc) |
| 25 | + * |
| 26 | + * See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md |
| 27 | + */ |
| 28 | + if (!this.style.backgroundImage) { |
| 29 | + this.style.backgroundImage = `url("https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg")`; |
| 30 | + this.upgradePosterImage(); |
| 31 | + } |
| 32 | + |
| 33 | + // Set up play button, and its visually hidden label |
| 34 | + if (!playBtnEl) { |
| 35 | + playBtnEl = document.createElement('button'); |
| 36 | + playBtnEl.type = 'button'; |
| 37 | + playBtnEl.classList.add('lty-playbtn'); |
| 38 | + this.append(playBtnEl); |
| 39 | + } |
| 40 | + if (!playBtnEl.textContent) { |
| 41 | + const playBtnLabelEl = document.createElement('span'); |
| 42 | + playBtnLabelEl.className = 'lyt-visually-hidden'; |
| 43 | + playBtnLabelEl.textContent = this.playLabel; |
| 44 | + playBtnEl.append(playBtnLabelEl); |
| 45 | + } |
| 46 | + |
| 47 | + this.addNoscriptIframe(); |
| 48 | + |
| 49 | + // for the PE pattern, change anchor's semantics to button |
| 50 | + if(playBtnEl.nodeName === 'A'){ |
| 51 | + playBtnEl.removeAttribute('href'); |
| 52 | + playBtnEl.setAttribute('tabindex', '0'); |
| 53 | + playBtnEl.setAttribute('role', 'button'); |
| 54 | + // fake button needs keyboard help |
| 55 | + playBtnEl.addEventListener('keydown', e => { |
| 56 | + if( e.key === 'Enter' || e.key === ' ' ){ |
| 57 | + e.preventDefault(); |
| 58 | + this.activate(); |
| 59 | + } |
| 60 | + }); |
| 61 | + } |
| 62 | + |
| 63 | + // On hover (or tap), warm up the TCP connections we're (likely) about to use. |
| 64 | + this.addEventListener('pointerover', LiteYTEmbed.warmConnections, {once: true}); |
| 65 | + this.addEventListener('focusin', LiteYTEmbed.warmConnections, {once: true}); |
| 66 | + |
| 67 | + // Once the user clicks, add the real iframe and drop our play button |
| 68 | + // TODO: In the future we could be like amp-youtube and silently swap in the iframe during idle time |
| 69 | + // We'd want to only do this for in-viewport or near-viewport ones: https://github.com/ampproject/amphtml/pull/5003 |
| 70 | + this.addEventListener('click', this.activate); |
| 71 | + |
| 72 | + // Chrome & Edge desktop have no problem with the basic YouTube Embed with ?autoplay=1 |
| 73 | + // However Safari desktop and most/all mobile browsers do not successfully track the user gesture of clicking through the creation/loading of the iframe, |
| 74 | + // so they don't autoplay automatically. Instead we must load an additional 2 sequential JS files (1KB + 165KB) (un-br) for the YT Player API |
| 75 | + // TODO: Try loading the the YT API in parallel with our iframe and then attaching/playing it. #82 |
| 76 | + this.needsYTApi = this.hasAttribute("js-api") || navigator.vendor.includes('Apple') || navigator.userAgent.includes('Mobi'); |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Add a <link rel={preload | preconnect} ...> to the head |
| 81 | + */ |
| 82 | + static addPrefetch(kind, url, as) { |
| 83 | + const linkEl = document.createElement('link'); |
| 84 | + linkEl.rel = kind; |
| 85 | + linkEl.href = url; |
| 86 | + if (as) { |
| 87 | + linkEl.as = as; |
| 88 | + } |
| 89 | + document.head.append(linkEl); |
| 90 | + } |
| 91 | + |
| 92 | + /** |
| 93 | + * Begin pre-connecting to warm up the iframe load |
| 94 | + * Since the embed's network requests load within its iframe, |
| 95 | + * preload/prefetch'ing them outside the iframe will only cause double-downloads. |
| 96 | + * So, the best we can do is warm up a few connections to origins that are in the critical path. |
| 97 | + * |
| 98 | + * Maybe `<link rel=preload as=document>` would work, but it's unsupported: http://crbug.com/593267 |
| 99 | + * But TBH, I don't think it'll happen soon with Site Isolation and split caches adding serious complexity. |
| 100 | + */ |
| 101 | + static warmConnections() { |
| 102 | + if (LiteYTEmbed.preconnected) return; |
| 103 | + |
| 104 | + // The iframe document and most of its subresources come right off youtube.com |
| 105 | + LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube-nocookie.com'); |
| 106 | + // The botguard script is fetched off from google.com |
| 107 | + LiteYTEmbed.addPrefetch('preconnect', 'https://www.google.com'); |
| 108 | + |
| 109 | + // Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling. |
| 110 | + LiteYTEmbed.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net'); |
| 111 | + LiteYTEmbed.addPrefetch('preconnect', 'https://static.doubleclick.net'); |
| 112 | + |
| 113 | + LiteYTEmbed.preconnected = true; |
| 114 | + } |
| 115 | + |
| 116 | + fetchYTPlayerApi() { |
| 117 | + if (window.YT || (window.YT && window.YT.Player)) return; |
| 118 | + |
| 119 | + this.ytApiPromise = new Promise((res, rej) => { |
| 120 | + var el = document.createElement('script'); |
| 121 | + el.src = 'https://www.youtube.com/iframe_api'; |
| 122 | + el.async = true; |
| 123 | + el.onload = _ => { |
| 124 | + YT.ready(res); |
| 125 | + }; |
| 126 | + el.onerror = rej; |
| 127 | + this.append(el); |
| 128 | + }); |
| 129 | + } |
| 130 | + |
| 131 | + /** Return the YT Player API instance. (Public L-YT-E API) */ |
| 132 | + async getYTPlayer() { |
| 133 | + if(!this.playerPromise) { |
| 134 | + await this.activate(); |
| 135 | + } |
| 136 | + |
| 137 | + return this.playerPromise; |
| 138 | + } |
| 139 | + |
| 140 | + async addYTPlayerIframe() { |
| 141 | + this.fetchYTPlayerApi(); |
| 142 | + await this.ytApiPromise; |
| 143 | + |
| 144 | + const videoPlaceholderEl = document.createElement('div') |
| 145 | + this.append(videoPlaceholderEl); |
| 146 | + |
| 147 | + const paramsObj = Object.fromEntries(this.getParams().entries()); |
| 148 | + |
| 149 | + this.playerPromise = new Promise(resolve => { |
| 150 | + let player = new YT.Player(videoPlaceholderEl, { |
| 151 | + width: '100%', |
| 152 | + videoId: this.videoId, |
| 153 | + playerVars: paramsObj, |
| 154 | + events: { |
| 155 | + 'onReady': event => { |
| 156 | + event.target.playVideo(); |
| 157 | + resolve(player); |
| 158 | + } |
| 159 | + } |
| 160 | + }); |
| 161 | + }); |
| 162 | + } |
| 163 | + |
| 164 | + // Add the iframe within <noscript> for indexability discoverability. See https://github.com/paulirish/lite-youtube-embed/issues/105 |
| 165 | + addNoscriptIframe() { |
| 166 | + const iframeEl = this.createBasicIframe(); |
| 167 | + const noscriptEl = document.createElement('noscript'); |
| 168 | + // Appending into noscript isn't equivalant for mysterious reasons: https://html.spec.whatwg.org/multipage/scripting.html#the-noscript-element |
| 169 | + noscriptEl.innerHTML = iframeEl.outerHTML; |
| 170 | + this.append(noscriptEl); |
| 171 | + } |
| 172 | + |
| 173 | + getParams() { |
| 174 | + const params = new URLSearchParams(this.getAttribute('params') || []); |
| 175 | + params.append('autoplay', '1'); |
| 176 | + params.append('playsinline', '1'); |
| 177 | + return params; |
| 178 | + } |
| 179 | + |
| 180 | + async activate(){ |
| 181 | + if (this.classList.contains('lyt-activated')) return; |
| 182 | + this.classList.add('lyt-activated'); |
| 183 | + |
| 184 | + if (this.needsYTApi) { |
| 185 | + return this.addYTPlayerIframe(this.getParams()); |
| 186 | + } |
| 187 | + |
| 188 | + const iframeEl = this.createBasicIframe(); |
| 189 | + this.append(iframeEl); |
| 190 | + |
| 191 | + // Set focus for a11y |
| 192 | + iframeEl.focus(); |
| 193 | + } |
| 194 | + |
| 195 | + createBasicIframe(){ |
| 196 | + const iframeEl = document.createElement('iframe'); |
| 197 | + iframeEl.width = 560; |
| 198 | + iframeEl.height = 315; |
| 199 | + // No encoding necessary as [title] is safe. https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#:~:text=Safe%20HTML%20Attributes%20include |
| 200 | + iframeEl.title = this.playLabel; |
| 201 | + iframeEl.allow = 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'; |
| 202 | + iframeEl.allowFullscreen = true; |
| 203 | + // AFAIK, the encoding here isn't necessary for XSS, but we'll do it only because this is a URL |
| 204 | + // https://stackoverflow.com/q/64959723/89484 |
| 205 | + iframeEl.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(this.videoId)}?${this.getParams().toString()}`; |
| 206 | + return iframeEl; |
| 207 | + } |
| 208 | + |
| 209 | + /** |
| 210 | + * In the spirit of the `lowsrc` attribute and progressive JPEGs, we'll upgrade the reliable |
| 211 | + * poster image to a higher resolution one, if it's available. |
| 212 | + * Interestingly this sddefault webp is often smaller in filesize, but we will still attempt it second |
| 213 | + * because getting _an_ image in front of the user if our first priority. |
| 214 | + * |
| 215 | + * See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md for more details |
| 216 | + */ |
| 217 | + upgradePosterImage() { |
| 218 | + // Defer to reduce network contention. |
| 219 | + setTimeout(() => { |
| 220 | + const webpUrl = `https://i.ytimg.com/vi_webp/${this.videoId}/sddefault.webp`; |
| 221 | + const img = new Image(); |
| 222 | + img.fetchPriority = 'low'; // low priority to reduce network contention |
| 223 | + img.referrerpolicy = 'origin'; // Not 100% sure it's needed, but https://github.com/ampproject/amphtml/pull/3940 |
| 224 | + img.src = webpUrl; |
| 225 | + img.onload = e => { |
| 226 | + // A pretty ugly hack since onerror won't fire on YouTube image 404. This is (probably) due to |
| 227 | + // Youtube's style of returning data even with a 404 status. That data is a 120x90 placeholder image. |
| 228 | + // … per "annoying yt 404 behavior" in the .md |
| 229 | + const noAvailablePoster = e.target.naturalHeight == 90 && e.target.naturalWidth == 120; |
| 230 | + if (noAvailablePoster) return; |
| 231 | + |
| 232 | + this.style.backgroundImage = `url("${webpUrl}")`; |
| 233 | + } |
| 234 | + }, 100); |
| 235 | + } |
| 236 | +} |
| 237 | +// Register custom element |
| 238 | +customElements.define('lite-youtube', LiteYTEmbed); |
0 commit comments