diff --git a/blocks/embed/lite-yt-embed.css b/blocks/embed/lite-yt-embed.css
new file mode 100644
index 00000000..089e1931
--- /dev/null
+++ b/blocks/embed/lite-yt-embed.css
@@ -0,0 +1,90 @@
+/* copied from https://github.com/paulirish/lite-youtube-embed/releases/tag/v0.2.0 */
+/* stylelint-disable */
+
+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 {
+ 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.js b/blocks/embed/lite-yt-embed.js
new file mode 100644
index 00000000..4de5f989
--- /dev/null
+++ b/blocks/embed/lite-yt-embed.js
@@ -0,0 +1,127 @@
+// Copied from https://github.com/paulirish/lite-youtube-embed/releases/tag/v0.2.0
+/* eslint-disable */
+/**
+ * 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.posterUrl = `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`;
+ // Warm the connection for the poster image
+ LiteYTEmbed.addPrefetch('preload', this.posterUrl, 'image');
+
+ this.style.backgroundImage = `url("${this.posterUrl}")`;
+ }
+
+ // 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);
+ }
+
+ // 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());
+ }
+
+ // // TODO: Support the the user changing the [videoid] attribute
+ // attributeChangedCallback() {
+ // }
+
+ /**
+ * 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;
+ }
+
+ addIframe() {
+ const params = new URLSearchParams(this.getAttribute('params') || []);
+ params.append('autoplay', '1');
+
+ 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);
+
+ this.classList.add('lyt-activated');
+
+ // Set focus for a11y
+ this.querySelector('iframe').focus();
+ }
+}
+// Register custom element
+customElements.define('lite-youtube', LiteYTEmbed);
diff --git a/scripts/scripts.js b/scripts/scripts.js
index 7bf57938..8fda48a9 100644
--- a/scripts/scripts.js
+++ b/scripts/scripts.js
@@ -11,6 +11,7 @@ import {
waitForLCP,
loadBlocks,
loadCSS,
+ loadScript,
getMetadata, decorateBlock, loadBlock,
} from './aem.js';
@@ -113,6 +114,42 @@ function buildLiveByMetadata(main) {
}
}
+export function getYoutubeVideoId(url) {
+ if (url.includes('youtube.com/watch?v=')) {
+ return new URL(url).searchParams.get('v');
+ }
+ if (url.includes('youtube.com/embed/') || url.includes('youtu.be/')) {
+ return new URL(url).pathname.split('/').pop();
+ }
+ return null;
+}
+
+function decorateVideoLinks(main) {
+ [...main.querySelectorAll('a')]
+ .filter(({ href }) => !!href)
+ // only convert plain links
+ .filter((a) => a.textContent?.trim()?.toLowerCase().startsWith('http'))
+ // don't decorate if already in a block. unless it's `columns`.
+ .filter((a) => {
+ const block = a.closest('div.block');
+ if (!block) return true;
+ return block.classList.contains('columns');
+ })
+ .forEach((link) => {
+ const youtubeVideoId = getYoutubeVideoId(link.href);
+
+ if (youtubeVideoId) {
+ loadCSS('/blocks/embed/lite-yt-embed.css');
+ loadScript('/blocks/embed/lite-yt-embed.js');
+ const video = document.createElement('lite-youtube');
+ video.setAttribute('videoid', youtubeVideoId);
+ video.setAttribute('params', 'rel=0');
+ video.classList.add('youtube-video');
+ link.replaceWith(video);
+ }
+ });
+}
+
/**
* Build Floating image block
* @param {Element} main The container element
@@ -214,6 +251,7 @@ export function decorateMain(main) {
buildAutoBlocks(main);
decorateSections(main);
decorateBlocks(main);
+ decorateVideoLinks(main);
}
/**