2024-04-28 17:33:09 +02:00
/ * *
* 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 ( ) ;
2024-08-28 22:54:34 +02:00
// 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 ( ) ;
}
} ) ;
}
2024-04-28 17:33:09 +02:00
// On hover (or tap), warm up the TCP connections we're (likely) about to use.
this . addEventListener ( 'pointerover' , LiteYTEmbed . warmConnections , { once : true } ) ;
2024-08-28 22:54:34 +02:00
this . addEventListener ( 'focusin' , LiteYTEmbed . warmConnections , { once : true } ) ;
2024-04-28 17:33:09 +02:00
// 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 ) ;