From 45cb9c88ce822b04ba43c1889007df3de25cab70 Mon Sep 17 00:00:00 2001 From: Darin Kuntze Date: Thu, 26 Oct 2023 15:49:14 -0500 Subject: [PATCH] facade for yt videos --- .eslintignore | 3 +- blocks/embed/embed.css | 114 ++++------ blocks/embed/embed.js | 225 ++++++++++++------- blocks/embed/lite-yt-embed/lite-yt-embed.css | 86 +++++++ blocks/embed/lite-yt-embed/lite-yt-embed.js | 166 ++++++++++++++ scripts/scripts.js | 21 ++ 6 files changed, 466 insertions(+), 149 deletions(-) create mode 100644 blocks/embed/lite-yt-embed/lite-yt-embed.css create mode 100644 blocks/embed/lite-yt-embed/lite-yt-embed.js diff --git a/.eslintignore b/.eslintignore index f999efb2..1e9ff1f9 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ scripts/jquery-1.11.2.min.js scripts/jquery.colorbox-min.js scripts/swiper-342.jquery.min.js scripts/main-scripts.js -scripts/gcse.js \ No newline at end of file +scripts/gcse.js +blocks/embed/lite-yt-embed/lite-yt-embed.js diff --git a/blocks/embed/embed.css b/blocks/embed/embed.css index dbc29cc8..e5d788c2 100644 --- a/blocks/embed/embed.css +++ b/blocks/embed/embed.css @@ -1,65 +1,49 @@ -main .embed { - width: unset; - text-align: center; - max-width: 800px; - margin: 32px auto; - } - - main .embed > div { - display: flex; - justify-content: center; - } - - main .embed.embed-twitter .twitter-tweet-rendered { - margin-left: auto; - margin-right: auto; - } - - main .embed .embed-placeholder { - width: 100%; - aspect-ratio: 16 / 9; - position: relative; - } - - main .embed .embed-placeholder > * { - display: flex; - align-items: center; - justify-content: center; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - } - - main .embed .embed-placeholder picture img { - width: 100%; - height: 100%; - object-fit: cover; - } - - main .embed .embed-placeholder-play button { - box-sizing: border-box; - position: relative; - display: block; - transform: scale(3); - width: 22px; - height: 22px; - border: 2px solid; - border-radius: 20px; - padding: 0; - } - - main .embed .embed-placeholder-play button::before { - content: ""; - display: block; - box-sizing: border-box; - position: absolute; - width: 0; - height: 10px; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-left: 6px solid; - top: 4px; - left: 7px; - } \ No newline at end of file +/* +* Copyright 2021 Adobe. All rights reserved. +* This file is licensed to you under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. You may obtain a copy +* of the License at http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software distributed under +* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +* OF ANY KIND, either express or implied. See the License for the specific language +* governing permissions and limitations under the License. +*/ +.embed { + display: flex; + margin: 0 auto 2rem; + padding: 0; + flex-direction: column; + align-items: stretch; + max-width: 100%; +} + +.embed > div, +.embed blockquote, +.embed iframe { + flex: 1; +} + +.embed iframe { + border: 1px solid var(--color-gray-light-alt); + border-radius: .5rem; +} + +.embed img { + max-width: 100%; +} + +.embed.tiktok lite-tiktok { + margin: 0 auto; + width: 518px; + height: 738px; +} + +.embed.instagram { + width: 325px; + min-height: 739px; +} + +.embed.instagram > div { + display: flex; +} diff --git a/blocks/embed/embed.js b/blocks/embed/embed.js index 1634dddf..465c85e9 100644 --- a/blocks/embed/embed.js +++ b/blocks/embed/embed.js @@ -1,113 +1,172 @@ -/* - * Embed Block - * Show videos and social posts directly on your page - * https://www.hlx.live/developer/block-collection/embed - */ +import { loadCSS, loadScript } from '../../scripts/aem.js'; -const loadScript = (url, callback, type) => { - const head = document.querySelector('head'); - const script = document.createElement('script'); - script.src = url; - if (type) { - script.setAttribute('type', type); - } - script.onload = callback; - head.append(script); - return script; -}; +const FALLBACK_PUBLICATION_DATE = '2013-07-18'; // go-live date for the Franklin site, but could be any value -const getDefaultEmbed = (url) => `
- -
`; +const getDefaultEmbed = (url) => ``; + +const embedYoutubeFacade = async (url) => { + loadCSS('/blocks/embed/lite-yt-embed/lite-yt-embed.css'); + loadScript('/blocks/embed/lite-yt-embed/lite-yt-embed.js'); -const embedYoutube = (url, autoplay) => { const usp = new URLSearchParams(url.search); - const suffix = autoplay ? '&muted=1&autoplay=1' : ''; - let vid = encodeURIComponent(usp.get('v')); - const embed = url.pathname; + let videoId = usp.get('v'); if (url.origin.includes('youtu.be')) { - [, vid] = url.pathname.split('/'); + videoId = url.pathname.substring(1); + } else { + videoId = url.pathname.split('/').pop(); } - const embedHTML = `
- -
`; - return embedHTML; + const wrapper = document.createElement('div'); + wrapper.setAttribute('itemscope', ''); + wrapper.setAttribute('itemtype', 'https://schema.org/VideoObject'); + const litePlayer = document.createElement('lite-youtube'); + litePlayer.setAttribute('videoid', videoId); + wrapper.append(litePlayer); + + try { + const response = await fetch(`https://www.youtube.com/oembed?url=http://www.youtube.com/watch?v=${videoId}`); + const json = await response.json(); + wrapper.innerHTML = ` + + + + + + ${wrapper.innerHTML} + `; + } catch (err) { + // Nothing to do, metadata just won't be added to the video + } + return wrapper.outerHTML; }; -const embedVimeo = (url, autoplay) => { - const [, video] = url.pathname.split('/'); - const suffix = autoplay ? '?muted=1&autoplay=1' : ''; - const embedHTML = `
- -
`; +const embedInstagram = (url) => { + const endingSlash = url.pathname.endsWith('/') ? '' : '/'; + const location = window.location.href.endsWith('.html') ? window.location.href : `${window.location.href}.html`; + const src = `${url.origin}${url.pathname}${endingSlash}embed/captioned/?rd=${window.encodeURIComponent(location)}`; + const embedHTML = ` +
+ + +
+ `; return embedHTML; }; const embedTwitter = (url) => { - const embedHTML = `
`; loadScript('https://platform.twitter.com/widgets.js'); + const embedHTML = ` +
+ +
+ `; return embedHTML; }; -const loadEmbed = (block, link, autoplay) => { - if (block.classList.contains('embed-is-loaded')) { - return; +const embedTiktokFacade = async (url) => { + loadScript('/blocks/embed/lite-tiktok/lite-tiktok.js', () => {}, { async: true, type: 'module' }); + const videoId = url.pathname.split('/').pop(); + try { + const request = await fetch(`https://www.tiktok.com/oembed?url=https://www.tiktok.com/video/${videoId}`); + const json = await request.json(); + return ` +
+ + + + + +
+ `; + } catch (err) { + return ` +
+ + +
+ `; } +}; - const EMBEDS_CONFIG = [ - { - match: ['youtube', 'youtu.be'], - embed: embedYoutube, - }, - { - match: ['vimeo'], - embed: embedVimeo, - }, - { - match: ['twitter'], - embed: embedTwitter, - }, - ]; - - const config = EMBEDS_CONFIG.find((e) => e.match.some((match) => link.includes(match))); - const url = new URL(link); - if (config) { - block.innerHTML = config.embed(url, autoplay); - block.classList = `block embed embed-${config.match[0]}`; - } else { +const EMBEDS_CONFIG = { + instagram: embedInstagram, + tiktok: embedTiktokFacade, + twitter: embedTwitter, + youtube: embedYoutubeFacade, +}; + +function getPlatform(url) { + const [service] = url.hostname.split('.').slice(-2, -1); + if (service === 'youtu') { + return 'youtube'; + } + return service; +} + +const loadEmbed = async (block, service, url) => { + block.classList.toggle('skeleton', true); + + const embed = EMBEDS_CONFIG[service]; + if (!embed) { + block.classList.toggle('generic', true); block.innerHTML = getDefaultEmbed(url); - block.classList = 'block embed'; + return; + } + + try { + block.classList.toggle(service, true); + try { + block.innerHTML = await embed(url); + } catch (err) { + block.style.display = 'none'; + } finally { + block.classList.toggle('skeleton', false); + } + } catch (err) { + block.style.maxHeight = '0px'; } - block.classList.add('embed-is-loaded'); }; +// Listen for messages from instagram embeds to update the embed height. +window.addEventListener('message', (ev) => { + const iframe = [...document.querySelectorAll('iframe')].find((i) => i.contentWindow === ev.source); + if (!iframe) { + return; + } + let data; + try { + data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; + } catch (e) { + // Nothing to do, the message isn't the one we are looking for + return; + } + + if (data.type !== 'MEASURE') { + return; + } + + iframe.closest('.block').style.height = `${data.details.height}px`; +}); + +/** + * @param {HTMLDivElement} block + */ export default function decorate(block) { - const placeholder = block.querySelector('picture'); - const link = block.querySelector('a').href; - block.textContent = ''; + const url = new URL(block.querySelector('a').href.replace(/%5C%5C_/, '_')); - if (placeholder) { - const wrapper = document.createElement('div'); - wrapper.className = 'embed-placeholder'; - wrapper.innerHTML = '
'; - wrapper.prepend(placeholder); - wrapper.addEventListener('click', () => { - loadEmbed(block, link, true); - }); - block.append(wrapper); - } else { + block.textContent = ''; + const service = getPlatform(url); + // Both Youtube and TikTok use an optimized lib that already leverages the intersection observer + if (service !== 'tiktok' && service !== 'youtube') { const observer = new IntersectionObserver((entries) => { - if (entries.some((e) => e.isIntersecting)) { - observer.disconnect(); - loadEmbed(block, link); + if (!entries.some((e) => e.isIntersecting)) { + return; } + + loadEmbed(block, service, url); + observer.unobserve(block); }); observer.observe(block); + } else { + loadEmbed(block, service, url); } } diff --git a/blocks/embed/lite-yt-embed/lite-yt-embed.css b/blocks/embed/lite-yt-embed/lite-yt-embed.css new file mode 100644 index 00000000..0a24ae76 --- /dev/null +++ b/blocks/embed/lite-yt-embed/lite-yt-embed.css @@ -0,0 +1,86 @@ +lite-youtube { + background-color: #000; + position: relative; + display: block; + contain: content; + background-position: center center; + background-size: cover; + cursor: pointer; + max-width: 720px; +} + +/* gradient */ +lite-youtube::before { + content: ''; + display: block; + position: absolute; + top: 0; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==); + background-position: top; + background-repeat: repeat-x; + height: 60px; + padding-bottom: 50px; + width: 100%; + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); +} + +/* responsive iframe with a 16:9 aspect ratio + thanks https://css-tricks.com/responsive-iframes/ +*/ +lite-youtube::after { + content: ""; + display: block; + padding-bottom: calc(100% / (16 / 9)); +} +lite-youtube > iframe { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + border: 0; +} + +/* play button */ +lite-youtube > .lty-playbtn { + display: block; + width: 68px; + height: 48px; + position: absolute; + cursor: pointer; + transform: translate3d(-50%, -50%, 0); + top: 50%; + left: 50%; + z-index: 1; + background-color: transparent; + /* YT's actual play button svg */ + background-image: url('data:image/svg+xml;utf8,'); + filter: grayscale(100%); + transition: filter .1s cubic-bezier(0, 0, 0.2, 1); + border: none; +} + +lite-youtube:hover > .lty-playbtn, +lite-youtube .lty-playbtn:focus { + filter: none; +} + +/* Post-click styles */ +lite-youtube.lyt-activated { + cursor: unset; +} +lite-youtube.lyt-activated::before, +lite-youtube.lyt-activated > .lty-playbtn { + opacity: 0; + pointer-events: none; +} + +.lyt-visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/blocks/embed/lite-yt-embed/lite-yt-embed.js b/blocks/embed/lite-yt-embed/lite-yt-embed.js new file mode 100644 index 00000000..1e45a303 --- /dev/null +++ b/blocks/embed/lite-yt-embed/lite-yt-embed.js @@ -0,0 +1,166 @@ +/** + * 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 + */ +class LiteYTEmbed extends HTMLElement { + connectedCallback() { + this.videoId = this.getAttribute('videoid'); + + let playBtnEl = this.querySelector('.lty-playbtn'); + // A label for the button takes priority over a [playlabel] attribute on the custom-element + this.playLabel = (playBtnEl && playBtnEl.textContent.trim()) || this.getAttribute('playlabel') || 'Play'; + + /** + * Lo, the youtube placeholder image! (aka the thumbnail, poster image, etc) + * + * See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md + * + * 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 + */ + if (!this.style.backgroundImage) { + this.style.backgroundImage = `url("https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg")`; + } + + // Set up play button, and its visually hidden label + if (!playBtnEl) { + playBtnEl = document.createElement('button'); + playBtnEl.type = 'button'; + playBtnEl.classList.add('lty-playbtn'); + this.append(playBtnEl); + } + if (!playBtnEl.textContent) { + const playBtnLabelEl = document.createElement('span'); + playBtnLabelEl.className = 'lyt-visually-hidden'; + playBtnLabelEl.textContent = this.playLabel; + playBtnEl.append(playBtnLabelEl); + } + playBtnEl.removeAttribute('href'); + + // 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', this.addIframe); + + // Chrome & Edge desktop have no problem with the basic YouTube Embed with ?autoplay=1 + // However Safari desktop and most/all mobile browsers do not successfully track the user gesture of clicking through the creation/loading of the iframe, + // 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 + // TODO: Try loading the the YT API in parallel with our iframe and then attaching/playing it. #82 + this.needsYTApiForAutoplay = navigator.vendor.includes('Apple') || navigator.userAgent.includes('Mobi'); + } + + /** + * Add a to the head + */ + static addPrefetch(kind, url, as) { + const linkEl = document.createElement('link'); + linkEl.rel = kind; + linkEl.href = url; + if (as) { + linkEl.as = as; + } + document.head.append(linkEl); + } + + /** + * Begin pre-connecting to warm up the iframe load + * Since the embed's network 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. + */ + static warmConnections() { + if (LiteYTEmbed.preconnected) return; + + // The iframe document and most of its subresources come right off youtube.com + LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube-nocookie.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. + LiteYTEmbed.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net'); + LiteYTEmbed.addPrefetch('preconnect', 'https://static.doubleclick.net'); + + LiteYTEmbed.preconnected = true; + } + + fetchYTPlayerApi() { + if (window.YT || (window.YT && window.YT.Player)) return; + + this.ytApiPromise = new Promise((res, rej) => { + const el = document.createElement('script'); + el.src = 'https://www.youtube.com/iframe_api'; + el.async = true; + el.onload = (_) => { + YT.ready(res); + }; + el.onerror = rej; + this.append(el); + }); + } + + async addYTPlayerIframe(params) { + this.fetchYTPlayerApi(); + await this.ytApiPromise; + + const videoPlaceholderEl = document.createElement('div'); + this.append(videoPlaceholderEl); + + const paramsObj = Object.fromEntries(params.entries()); + + new YT.Player(videoPlaceholderEl, { + width: '100%', + videoId: this.videoId, + playerVars: paramsObj, + events: { + onReady: (event) => { + event.target.playVideo(); + }, + }, + }); + } + + async addIframe() { + if (this.classList.contains('lyt-activated')) return; + this.classList.add('lyt-activated'); + + const params = new URLSearchParams(this.getAttribute('params') || []); + params.append('autoplay', '1'); + params.append('playsinline', '1'); + + // if (this.needsYTApiForAutoplay) { + return this.addYTPlayerIframe(params); + // } + + const iframeEl = document.createElement('iframe'); + iframeEl.width = 560; + iframeEl.height = 315; + // No encoding necessary as [title] is safe. https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#:~:text=Safe%20HTML%20Attributes%20include + iframeEl.title = this.playLabel; + iframeEl.allow = 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'; + iframeEl.allowFullscreen = true; + // AFAIK, the encoding here isn't necessary for XSS, but we'll do it only because this is a URL + // https://stackoverflow.com/q/64959723/89484 + iframeEl.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(this.videoId)}?${params.toString()}`; + this.append(iframeEl); + + // Set focus for a11y + iframeEl.focus(); + } +} +// Register custom element +customElements.define('lite-youtube', LiteYTEmbed); diff --git a/scripts/scripts.js b/scripts/scripts.js index d51331f2..bebe6ee6 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -7,6 +7,7 @@ import { decorateIcons, decorateSections, decorateBlocks, + decorateBlock, decorateTemplateAndTheme, waitForLCP, loadBlocks, @@ -72,6 +73,25 @@ async function decorateTemplates(main) { } } +/** + * Builds embed block for inline links to known social platforms. + * @param {Element} main The container element + */ +function buildEmbedBlocks(main) { + const HOSTNAMES = [ + 'youtube', + 'youtu', + ]; + [...main.querySelectorAll(':is(p, div) > a[href]:only-child')] + .filter((a) => HOSTNAMES.includes(new URL(a.href).hostname.split('.').slice(-2, -1).pop())) + .forEach((a) => { + const parent = a.parentElement; + const block = buildBlock('embed', { elems: [a] }); + parent.replaceWith(block); + decorateBlock(block); + }); +} + /** * Builds all synthetic blocks in a container element. * @param {Element} main The container element @@ -79,6 +99,7 @@ async function decorateTemplates(main) { function buildAutoBlocks(main) { try { buildHeroBlock(main); + buildEmbedBlocks(main); } catch (error) { // eslint-disable-next-line no-console console.error('Auto Blocking failed', error);