Skip to content

Commit

Permalink
Add video walkthrough to Usage docs
Browse files Browse the repository at this point in the history
  • Loading branch information
imacrayon committed Aug 25, 2024
1 parent ec52e82 commit 1ef1a18
Show file tree
Hide file tree
Showing 7 changed files with 358 additions and 3 deletions.
97 changes: 97 additions & 0 deletions docs/css/lite-yt-embed.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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: attr(data-title);
display: block;
position: absolute;
top: 0;
/* Pixel-perfect port of YT's gradient PNG, using https://github.com/bluesmoon/pngtocss plus optimizations */
background-image: linear-gradient(180deg, rgb(0 0 0 / 67%) 0%, rgb(0 0 0 / 54%) 14%, rgb(0 0 0 / 15%) 54%, rgb(0 0 0 / 5%) 72%, rgb(0 0 0 / 0%) 94%);
height: 99px;
width: 100%;
font-family: "YouTube Noto", Roboto, Arial, Helvetica, sans-serif;
color: hsl(0deg 0% 93.33%);
text-shadow: 0 0 2px rgba(0, 0, 0, .5);
font-size: 18px;
padding: 25px 20px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
}

lite-youtube:hover::before {
color: white;
}

/* 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;
/* Make the button element cover the whole area for a large hover/click target… */
width: 100%;
height: 100%;
/* …but visually it's still the same size */
background: no-repeat center/68px 48px;
/* YT's actual play button svg */
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24 27 14v20" fill="white"/></svg>');
position: absolute;
cursor: pointer;
z-index: 1;
filter: grayscale(100%);
transition: filter .1s cubic-bezier(0, 0, 0.2, 1);
border: 0;
}

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;
}
2 changes: 1 addition & 1 deletion docs/eleventy.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ module.exports = function (eleventyConfig) {
eleventyConfig.setLibrary('md', markdownLibrary)

eleventyConfig.setServerOptions({
watch: ['_sites/**/*.js'],
watch: ['_site/**/*.js'],
})

return {
Expand Down
238 changes: 238 additions & 0 deletions docs/js/lite-yt-embed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* 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';

this.dataset.title = this.getAttribute('title') || "";

/**
* Lo, the youtube poster image! (aka the thumbnail, image placeholder, etc)
*
* See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md
*/
if (!this.style.backgroundImage) {
this.style.backgroundImage = `url("https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg")`;
this.upgradePosterImage();
}

// 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);
}

this.addNoscriptIframe();

// for the PE pattern, change anchor's semantics to button
if (playBtnEl.nodeName === 'A') {
playBtnEl.removeAttribute('href');
playBtnEl.setAttribute('tabindex', '0');
playBtnEl.setAttribute('role', 'button');
// fake button needs keyboard help
playBtnEl.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.activate();
}
});
}

// On hover (or tap), warm up the TCP connections we're (likely) about to use.
this.addEventListener('pointerover', LiteYTEmbed.warmConnections, { once: true });
this.addEventListener('focusin', 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.activate);

// 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.needsYTApi = this.hasAttribute("js-api") || navigator.vendor.includes('Apple') || navigator.userAgent.includes('Mobi');
}

/**
* Add a <link rel={preload | preconnect} ...> 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 `<link rel=preload as=document>` 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) => {
var 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);
});
}

/** Return the YT Player API instance. (Public L-YT-E API) */
async getYTPlayer() {
if (!this.playerPromise) {
await this.activate();
}

return this.playerPromise;
}

async addYTPlayerIframe() {
this.fetchYTPlayerApi();
await this.ytApiPromise;

const videoPlaceholderEl = document.createElement('div')
this.append(videoPlaceholderEl);

const paramsObj = Object.fromEntries(this.getParams().entries());

this.playerPromise = new Promise(resolve => {
let player = new YT.Player(videoPlaceholderEl, {
width: '100%',
videoId: this.videoId,
playerVars: paramsObj,
events: {
'onReady': event => {
event.target.playVideo();
resolve(player);
}
}
});
});
}

// Add the iframe within <noscript> for indexability discoverability. See https://github.com/paulirish/lite-youtube-embed/issues/105
addNoscriptIframe() {
const iframeEl = this.createBasicIframe();
const noscriptEl = document.createElement('noscript');
// Appending into noscript isn't equivalant for mysterious reasons: https://html.spec.whatwg.org/multipage/scripting.html#the-noscript-element
noscriptEl.innerHTML = iframeEl.outerHTML;
this.append(noscriptEl);
}

getParams() {
const params = new URLSearchParams(this.getAttribute('params') || []);
params.append('autoplay', '1');
params.append('playsinline', '1');
return params;
}

async activate() {
if (this.classList.contains('lyt-activated')) return;
this.classList.add('lyt-activated');

if (this.needsYTApi) {
return this.addYTPlayerIframe(this.getParams());
}

const iframeEl = this.createBasicIframe();
this.append(iframeEl);

// Set focus for a11y
iframeEl.focus();
}

createBasicIframe() {
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)}?${this.getParams().toString()}`;
return iframeEl;
}

/**
* In the spirit of the `lowsrc` attribute and progressive JPEGs, we'll upgrade the reliable
* poster image to a higher resolution one, if it's available.
* Interestingly this sddefault webp is often smaller in filesize, but we will still attempt it second
* because getting _an_ image in front of the user if our first priority.
*
* See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md for more details
*/
upgradePosterImage() {
// Defer to reduce network contention.
setTimeout(() => {
const webpUrl = `https://i.ytimg.com/vi_webp/${this.videoId}/sddefault.webp`;
const img = new Image();
img.fetchPriority = 'low'; // low priority to reduce network contention
img.referrerpolicy = 'origin'; // Not 100% sure it's needed, but https://github.com/ampproject/amphtml/pull/3940
img.src = webpUrl;
img.onload = e => {
// A pretty ugly hack since onerror won't fire on YouTube image 404. This is (probably) due to
// Youtube's style of returning data even with a 404 status. That data is a 120x90 placeholder image.
// … per "annoying yt 404 behavior" in the .md
const noAvailablePoster = e.target.naturalHeight == 90 && e.target.naturalWidth == 120;
if (noAvailablePoster) return;

this.style.backgroundImage = `url("${webpUrl}")`;
}
}, 100);
}
}
// Register custom element
customElements.define('lite-youtube', LiteYTEmbed);
3 changes: 3 additions & 0 deletions docs/reference.njk
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ eleventyNavigation:
order: 1
---

{% css %}{% include "css/lite-yt-embed.css" %}{% endcss %}
{% js %}{% include "js/lite-yt-embed.js" %}{% endjs %}

<h1>Reference</h1>

{% for entry in collections.reference | sort('data.eleventyNavigation.order') %}
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,11 @@ eleventyNavigation:
## Usage

It’s good practice to start building your UI **without** Alpine AJAX. Make your entire website work as it would if Alpine AJAX were not available, then sprinkle in AJAX functionality at the end. Working in this way will ensure that your AJAX interactions degrade gracefully [when JavaScript is not available](https://www.kryogenix.org/code/browser/everyonehasjs.html): Links and forms will continue to work as normal, they simply won't fire AJAX requests. This is known as [Progressive Enhancement](https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement), and it allows a wider audience to use your website.

### See it in in action

<lite-youtube videoid="vNiZyFVmoOI" class="border-4 border-blue-800" style="background-image: url('https://i.ytimg.com/vi/vNiZyFVmoOI/hqdefault.jpg');">
<a href="https://youtube.com/watch?v=vNiZyFVmoOI" class="lty-playbtn" title="Play Video">
<span class="lyt-visually-hidden">Play Video: Alpine AJAX</span>
</a>
</lite-youtube>
Loading

0 comments on commit 1ef1a18

Please sign in to comment.