mirror of
https://github.com/owncast/owncast.git
synced 2024-11-29 19:49:31 +03:00
72de85bc10
* JS update workflow * Update package.json to see if workflow fires properly * Commit updated Javascript packages Co-authored-by: Owncast <owncast@owncast.online>
302 lines
10 KiB
JavaScript
302 lines
10 KiB
JavaScript
/**
|
|
*
|
|
* The shadowDom / Intersection Observer version of Paul's concept:
|
|
* https://github.com/paulirish/lite-youtube-embed
|
|
*
|
|
* 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 {
|
|
constructor() {
|
|
super();
|
|
this.iframeLoaded = false;
|
|
this.setupDom();
|
|
}
|
|
static get observedAttributes() {
|
|
return ['videoid'];
|
|
}
|
|
connectedCallback() {
|
|
this.addEventListener('pointerover', LiteYTEmbed.warmConnections, {
|
|
once: true,
|
|
});
|
|
this.addEventListener('click', () => this.addIframe());
|
|
}
|
|
get videoId() {
|
|
return encodeURIComponent(this.getAttribute('videoid') || '');
|
|
}
|
|
set videoId(id) {
|
|
this.setAttribute('videoid', id);
|
|
}
|
|
get videoTitle() {
|
|
return this.getAttribute('videotitle') || 'Video';
|
|
}
|
|
set videoTitle(title) {
|
|
this.setAttribute('videotitle', title);
|
|
}
|
|
get videoPlay() {
|
|
return this.getAttribute('videoPlay') || 'Play';
|
|
}
|
|
set videoPlay(name) {
|
|
this.setAttribute('videoPlay', name);
|
|
}
|
|
get videoStartAt() {
|
|
return Number(this.getAttribute('videoStartAt') || '0');
|
|
}
|
|
set videoStartAt(time) {
|
|
this.setAttribute('videoStartAt', String(time));
|
|
}
|
|
get autoLoad() {
|
|
return this.hasAttribute('autoload');
|
|
}
|
|
set autoLoad(value) {
|
|
if (value) {
|
|
this.setAttribute('autoload', '');
|
|
}
|
|
else {
|
|
this.removeAttribute('autoload');
|
|
}
|
|
}
|
|
get params() {
|
|
return `start=${this.videoStartAt}&${this.getAttribute('params')}`;
|
|
}
|
|
/**
|
|
* Define our shadowDOM for the component
|
|
*/
|
|
setupDom() {
|
|
const shadowDom = this.attachShadow({ mode: 'open' });
|
|
shadowDom.innerHTML = `
|
|
<style>
|
|
:host {
|
|
contain: content;
|
|
display: block;
|
|
position: relative;
|
|
width: 100%;
|
|
padding-bottom: calc(100% / (16 / 9));
|
|
}
|
|
|
|
#frame, #fallbackPlaceholder, iframe {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
#frame {
|
|
cursor: pointer;
|
|
}
|
|
|
|
#fallbackPlaceholder {
|
|
object-fit: cover;
|
|
}
|
|
|
|
#frame::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);
|
|
z-index: 1;
|
|
}
|
|
/* play button */
|
|
.lty-playbtn {
|
|
width: 70px;
|
|
height: 46px;
|
|
background-color: #212121;
|
|
z-index: 1;
|
|
opacity: 0.8;
|
|
border-radius: 14%; /* TODO: Consider replacing this with YT's actual svg. Eh. */
|
|
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
|
|
border: 0;
|
|
}
|
|
#frame:hover .lty-playbtn {
|
|
background-color: #f00;
|
|
opacity: 1;
|
|
}
|
|
/* play button triangle */
|
|
.lty-playbtn:before {
|
|
content: '';
|
|
border-style: solid;
|
|
border-width: 11px 0 11px 19px;
|
|
border-color: transparent transparent transparent #fff;
|
|
}
|
|
.lty-playbtn,
|
|
.lty-playbtn:before {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate3d(-50%, -50%, 0);
|
|
}
|
|
|
|
/* Post-click styles */
|
|
.lyt-activated {
|
|
cursor: unset;
|
|
}
|
|
|
|
#frame.lyt-activated::before,
|
|
.lyt-activated .lty-playbtn {
|
|
display: none;
|
|
}
|
|
</style>
|
|
<div id="frame">
|
|
<picture>
|
|
<source id="webpPlaceholder" type="image/webp">
|
|
<source id="jpegPlaceholder" type="image/jpeg">
|
|
<img id="fallbackPlaceholder" referrerpolicy="origin">
|
|
</picture>
|
|
<button class="lty-playbtn"></button>
|
|
</div>
|
|
`;
|
|
this.domRefFrame = this.shadowRoot.querySelector('#frame');
|
|
this.domRefImg = {
|
|
fallback: this.shadowRoot.querySelector('#fallbackPlaceholder'),
|
|
webp: this.shadowRoot.querySelector('#webpPlaceholder'),
|
|
jpeg: this.shadowRoot.querySelector('#jpegPlaceholder'),
|
|
};
|
|
this.domRefPlayButton = this.shadowRoot.querySelector('.lty-playbtn');
|
|
}
|
|
/**
|
|
* Parse our attributes and fire up some placeholders
|
|
*/
|
|
setupComponent() {
|
|
this.initImagePlaceholder();
|
|
this.domRefPlayButton.setAttribute('aria-label', `${this.videoPlay}: ${this.videoTitle}`);
|
|
this.setAttribute('title', `${this.videoPlay}: ${this.videoTitle}`);
|
|
if (this.autoLoad) {
|
|
this.initIntersectionObserver();
|
|
}
|
|
}
|
|
/**
|
|
* Lifecycle method that we use to listen for attribute changes to period
|
|
* @param {*} name
|
|
* @param {*} oldVal
|
|
* @param {*} newVal
|
|
*/
|
|
attributeChangedCallback(name, oldVal, newVal) {
|
|
switch (name) {
|
|
case 'videoid': {
|
|
if (oldVal !== newVal) {
|
|
this.setupComponent();
|
|
// if we have a previous iframe, remove it and the activated class
|
|
if (this.domRefFrame.classList.contains('lyt-activated')) {
|
|
this.domRefFrame.classList.remove('lyt-activated');
|
|
this.shadowRoot.querySelector('iframe').remove();
|
|
this.iframeLoaded = false;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Inject the iframe into the component body
|
|
*/
|
|
addIframe() {
|
|
if (!this.iframeLoaded) {
|
|
const iframeHTML = `
|
|
<iframe frameborder="0"
|
|
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen
|
|
src="https://www.youtube.com/embed/${this.videoId}?autoplay=1&${this.params}"
|
|
></iframe>`;
|
|
this.domRefFrame.insertAdjacentHTML('beforeend', iframeHTML);
|
|
this.domRefFrame.classList.add('lyt-activated');
|
|
this.iframeLoaded = true;
|
|
}
|
|
}
|
|
/**
|
|
* Setup the placeholder image for the component
|
|
*/
|
|
initImagePlaceholder() {
|
|
// we don't know which image type to preload, so warm the connection
|
|
LiteYTEmbed.addPrefetch('preconnect', 'https://i.ytimg.com/');
|
|
const posterUrlWebp = `https://i.ytimg.com/vi_webp/${this.videoId}/hqdefault.webp`;
|
|
const posterUrlJpeg = `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg`;
|
|
this.domRefImg.webp.srcset = posterUrlWebp;
|
|
this.domRefImg.jpeg.srcset = posterUrlJpeg;
|
|
this.domRefImg.fallback.src = posterUrlJpeg;
|
|
this.domRefImg.fallback.setAttribute('aria-label', `${this.videoPlay}: ${this.videoTitle}`);
|
|
this.domRefImg.fallback.setAttribute('alt', `${this.videoPlay}: ${this.videoTitle}`);
|
|
}
|
|
/**
|
|
* Setup the Intersection Observer to load the iframe when scrolled into view
|
|
*/
|
|
initIntersectionObserver() {
|
|
if ('IntersectionObserver' in window &&
|
|
'IntersectionObserverEntry' in window) {
|
|
const options = {
|
|
root: null,
|
|
rootMargin: '0px',
|
|
threshold: 0,
|
|
};
|
|
const observer = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting && !this.iframeLoaded) {
|
|
LiteYTEmbed.warmConnections();
|
|
this.addIframe();
|
|
observer.unobserve(this);
|
|
}
|
|
});
|
|
}, options);
|
|
observer.observe(this);
|
|
}
|
|
}
|
|
/**
|
|
* Add a <link rel={preload | preconnect} ...> to the head
|
|
* @param {*} kind
|
|
* @param {*} url
|
|
* @param {*} as
|
|
*/
|
|
static addPrefetch(kind, url, as) {
|
|
const linkElem = document.createElement('link');
|
|
linkElem.rel = kind;
|
|
linkElem.href = url;
|
|
if (as) {
|
|
linkElem.as = as;
|
|
}
|
|
linkElem.crossOrigin = 'true';
|
|
document.head.append(linkElem);
|
|
}
|
|
/**
|
|
* Begin preconnecting to warm up the iframe load Since the embed's netwok
|
|
* 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;
|
|
// Host that YT uses to serve JS needed by player, per amp-youtube
|
|
LiteYTEmbed.addPrefetch('preconnect', 'https://s.ytimg.com');
|
|
// The iframe document and most of its subresources come right off
|
|
// youtube.com
|
|
LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube.com');
|
|
// The botguard script is fetched off from google.com
|
|
LiteYTEmbed.addPrefetch('preconnect', 'https://www.google.com');
|
|
// TODO: 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;
|
|
}
|
|
}
|
|
LiteYTEmbed.preconnected = false;
|
|
// Register custom element
|
|
customElements.define('lite-youtube', LiteYTEmbed);
|
|
|
|
export { LiteYTEmbed };
|