Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions assets/js/video_facade.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
(function () {
"use strict";

var SEL = ".litespeed-video-facade";

function decode(f) {
try {
return JSON.parse(atob(f.dataset.lsvfAttrs || "")) || {};
} catch (e) {
return {};
}
}

function swap(f) {
if (f.dataset.lsvfSwapped) return;
f.dataset.lsvfSwapped = "1";

var a = decode(f),
i = document.createElement("iframe");

Object.keys(a).forEach(function (k) {
if (k === "src") return;
i.setAttribute(k, a[k]);
});

i.src = f.dataset.lsvfSrc;

if (!i.getAttribute("allow"))
i.setAttribute("allow", "autoplay; encrypted-media; fullscreen; picture-in-picture");

if (!i.hasAttribute("allowfullscreen"))
i.setAttribute("allowfullscreen", "");

f.parentNode.replaceChild(i, f);
}

// Vertical space (px) a pseudo-element reserves, or 0 if it isn't rendered.
// WP core block embeds reserve the ratio via `::before{content:"";padding-top:56.25%}`.
function pseudoReserved(p, pe) {
var s = getComputedStyle(p, pe);
if (!s || s.content === "none") return 0; // pseudo not generated -> reserves nothing
return (parseFloat(s.paddingTop) || 0) + (parseFloat(s.paddingBottom) || 0) + (parseFloat(s.height) || 0);
}

// Many themes (incl. WP core / block themes like Twenty Twenty-Six) wrap a
// video in a responsive container that reserves the aspect ratio with a
// percentage spacer and positions the *iframe* absolutely to fill it. Our
// facade replaces the iframe with a <div>, which the theme's `iframe` rule
// no longer matches, so the facade falls into normal flow and stacks below
// the reserved space -- leaving empty padding above/under the video. When we
// detect such a parent, mirror what the theme does to its iframe: absolutely
// fill the reserved box and drop our own intrinsic (aspect-ratio) sizing.
function fitToWrapper(f) {
var p = f.parentNode;
if (!p || p.nodeType !== 1 || typeof getComputedStyle !== "function") return;

var cs = getComputedStyle(p);
if (cs.position === "static") return; // responsive ratio wrappers are positioned

// Any video ratio reserves >= ~42% of the wrapper width (21:9); cosmetic
// padding is far smaller. Gate on that so we never collapse a normal parent.
var w = p.clientWidth || 0;
var threshold = Math.max(40, w * 0.3);

var reserved = Math.max(
(parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0),
pseudoReserved(p, "::before"),
pseudoReserved(p, "::after")
);
if (reserved < threshold) return;

f.style.position = "absolute";
f.style.top = "0";
f.style.left = "0";
f.style.right = "0";
f.style.bottom = "0";
f.style.width = "100%";
f.style.height = "100%";
f.style.maxWidth = "none";
f.style.setProperty("aspect-ratio", "auto");
}

function init() {
var fs = document.querySelectorAll(SEL);
if (!fs.length) return;

Array.prototype.forEach.call(fs, function (f) {
fitToWrapper(f);
f.addEventListener("click", function (e) {
e.preventDefault();
swap(f);
});
});
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
1 change: 1 addition & 0 deletions assets/js/video_facade.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
'src/ucss.cls.php',
'src/utility.cls.php',
'src/vary.cls.php',
'src/video.cls.php',
'src/vpi.cls.php',

// Extra CDN cls files
Expand Down
2 changes: 2 additions & 0 deletions src/base.cls.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ class Base extends Root {
const O_MEDIA_LQIP_MIN_H = 'media-lqip_min_h';
const O_MEDIA_PLACEHOLDER_RESP_ASYNC = 'media-placeholder_resp_async';
const O_MEDIA_IFRAME_LAZY = 'media-iframe_lazy';
const O_MEDIA_IFRAME_LAZY_VIDEO_IMG = 'media-iframe_lazy_video_img';
const O_MEDIA_ADD_MISSING_SIZES = 'media-add_missing_sizes';
const O_MEDIA_LAZY_EXC = 'media-lazy_exc';
const O_MEDIA_LAZY_CLS_EXC = 'media-lazy_cls_exc';
Expand Down Expand Up @@ -511,6 +512,7 @@ class Base extends Root {
self::O_MEDIA_LQIP_MIN_H => 0,
self::O_MEDIA_PLACEHOLDER_RESP_ASYNC => false,
self::O_MEDIA_IFRAME_LAZY => false,
self::O_MEDIA_IFRAME_LAZY_VIDEO_IMG => false,
self::O_MEDIA_ADD_MISSING_SIZES => false,
self::O_MEDIA_LAZY_EXC => [],
self::O_MEDIA_LAZY_CLS_EXC => [],
Expand Down
1 change: 1 addition & 0 deletions src/lang.cls.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ public static function title( $id ) {
self::O_MEDIA_LQIP_MIN_W => __( 'LQIP Minimum Dimensions', 'litespeed-cache' ),
self::O_MEDIA_PLACEHOLDER_RESP_ASYNC => __( 'Generate LQIP In Background', 'litespeed-cache' ),
self::O_MEDIA_IFRAME_LAZY => __( 'Lazy Load Iframes', 'litespeed-cache' ),
self::O_MEDIA_IFRAME_LAZY_VIDEO_IMG => __( 'Load Video Image', 'litespeed-cache' ),
self::O_MEDIA_ADD_MISSING_SIZES => __( 'Add Missing Sizes', 'litespeed-cache' ),
self::O_MEDIA_VPI => __( 'Viewport Images', 'litespeed-cache' ),
self::O_MEDIA_VPI_CRON => __( 'Viewport Images Cron', 'litespeed-cache' ),
Expand Down
106 changes: 106 additions & 0 deletions src/media.cls.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class Media extends Root {

const LIB_FILE_IMG_LAZYLOAD = 'assets/js/lazyload.min.js';

const LIB_FILE_VIDEO_FACADE = 'assets/js/video_facade.min.js';

const TYPE_BATCH_RESCALE_ORI = 'batch_rescale_ori';

/**
Expand Down Expand Up @@ -801,6 +803,7 @@ public function finalize( $content ) {
* Run lazyload replacement for images in buffer.
*
* @since 1.4
* @since 7.9 Added Video LazyLoad image facade support.
* @access private
* @return void
*/
Expand Down Expand Up @@ -831,6 +834,11 @@ private function _finalize() {

$cfg_lazy = ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_LAZY ) ) && ! $this->cls( 'Metabox' )->setting( 'litespeed_no_image_lazy' );
$cfg_iframe_lazy = defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_IFRAME_LAZY );
$cfg_video_img_set = $cfg_iframe_lazy && $this->conf( Base::O_MEDIA_IFRAME_LAZY_VIDEO_IMG );
$cfg_video_img = apply_filters( 'litespeed_media_iframe_lazy_video_img', $cfg_video_img_set );
if ( (bool) $cfg_video_img !== (bool) $cfg_video_img_set ) {
Video::debug( 'Video image facade setting overridden by filter.' );
}
$cfg_js_delay = defined( 'LITESPEED_GUEST_OPTM' ) || 2 === $this->conf( Base::O_OPTM_JS_DEFER );
$cfg_trim_noscript = defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_OPTM_NOSCRIPT_RM );
$cfg_vpi = defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_VPI );
Expand Down Expand Up @@ -866,6 +874,12 @@ private function _finalize() {
$this->content = str_replace( $html_list_ori, $html_list, $this->content );
}

// Replace known video iframes with image facades (must run before generic iframe lazy).
$video_facade_used = false;
if ( $cfg_video_img ) {
$video_facade_used = $this->_replace_video_iframes();
}

// iframe lazy load.
if ( $cfg_iframe_lazy ) {
$html_list = $this->_parse_iframe();
Expand All @@ -890,6 +904,10 @@ private function _finalize() {
// Include lazyload lib js and init lazyload.
if ( $cfg_lazy || $cfg_iframe_lazy ) {
$lazy_lib = '<script data-no-optimize="1">window.lazyLoadOptions=Object.assign({},{threshold:' . apply_filters( 'litespeed_lazyload_threshold', 300 ) . '},window.lazyLoadOptions||{});' . File::read( LSCWP_DIR . self::LIB_FILE_IMG_LAZYLOAD ) . '</script>';
if ( $video_facade_used ) {
$lazy_lib .= '<style>.litespeed-video-facade{position:relative;background-color:#000;background-size:cover;background-position:center;cursor:pointer;width:100%}.litespeed-video-facade .litespeed-video-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:68px;height:48px;border:0;background:rgba(0,0,0,.6);border-radius:14%;cursor:pointer;padding:0}.litespeed-video-facade .litespeed-video-play::before{content:"";position:absolute;top:50%;left:55%;transform:translate(-50%,-50%);border-style:solid;border-width:12px 0 12px 20px;border-color:transparent transparent transparent #fff}.litespeed-video-facade:hover .litespeed-video-play{background:#f00}</style>';
$lazy_lib .= '<script data-no-optimize="1">' . File::read( LSCWP_DIR . self::LIB_FILE_VIDEO_FACADE ) . '</script>';
}
if ( $cfg_js_delay ) {
// Load JS delay lib.
if ( ! defined( 'LITESPEED_JS_DELAY_LIB_LOADED' ) ) {
Expand Down Expand Up @@ -1167,6 +1185,94 @@ private function _detect_dimensions( $src ) {
return false;
}

/**
* Replace known video iframes (YouTube/Vimeo/Wistia/Dailymotion) with click-to-play
* image facades. The original <iframe> is removed entirely; a <div> placeholder
* carries the iframe's attributes (base64-encoded) so the JS can rebuild the
* iframe on click. Runs BEFORE the generic iframe lazy pass so the resulting <div>
* facades are invisible to its <iframe> regex.
*
* @since 7.9
* @return bool True if at least one iframe was replaced.
*/
private function _replace_video_iframes() {
$cls_excludes = apply_filters( 'litespeed_media_iframe_lazy_cls_excludes', $this->conf( Base::O_MEDIA_IFRAME_LAZY_CLS_EXC ) );
$cls_excludes[] = 'skip-lazy';
$parent_cls_excludes = apply_filters( 'litespeed_media_iframe_lazy_parent_cls_excludes', $this->conf( Base::O_MEDIA_IFRAME_LAZY_PARENT_CLS_EXC ) );

$content = preg_replace( '#<!--.*-->#sU', '', $this->content );
if ( $parent_cls_excludes ) {
foreach ( $parent_cls_excludes as $v ) {
$content = preg_replace( '#<(\w+) [^>]*class=(\'|")[^\'"]*' . preg_quote( $v, '#' ) . '[^\'"]*\2[^>]*>.*</\1>#sU', '', $content );
}
}

if ( ! preg_match_all( '#<iframe \s*([^>]+)></iframe>#isU', $content, $matches, PREG_SET_ORDER ) ) {
return false;
}

$search = array();
$replace = array();
$replaced = false;

foreach ( $matches as $match ) {
$attrs = Utility::parse_attr( $match[1] );
if ( empty( $attrs['src'] ) ) {
continue;
}
if ( ! empty( $attrs['data-no-lazy'] ) || ! empty( $attrs['data-skip-lazy'] ) || ! empty( $attrs['data-lazyloaded'] ) || ! empty( $attrs['data-src'] ) ) {
continue;
}
if ( ! empty( $attrs['class'] ) && Utility::str_hit_array( $attrs['class'], $cls_excludes ) ) {
continue;
}

// Decode HTML-entity ampersands so `&amp;` / `&#38;` / raw `&` all extract the same way.
$src_decoded = html_entity_decode( $attrs['src'], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$attrs['src'] = $src_decoded;

// Skip iframes already configured to autoplay — replacing them would require an
// extra click and break the author's intent. Matches autoplay=1 / autoplay=true
// (case-insensitive) after either `?` or `&`.
if ( preg_match( '#[?&]autoplay=(?:1|true)\b#i', $src_decoded ) ) {
continue;
}

// Substring-match URL skip via filter (e.g. add_filter('litespeed_video_lazy_skip_urls', fn($u)=>array_merge($u,['?keep_native=1'])))
if ( Video::url_skipped( $src_decoded ) ) {
continue;
}
// Per-call skip filter for code that needs richer logic.
if ( apply_filters( 'litespeed_video_lazy_skip', false, $src_decoded ) ) {
continue;
}

$matched = Video::extract_provider( $src_decoded );
if ( ! $matched ) {
continue; // Not a known video provider; let generic iframe lazy handle it.
}

// Avoid double-processing identical iframe markup.
if ( in_array( $match[0], $search, true ) ) {
continue;
}

$load_src = Video::ensure_autoplay( $matched['provider'], $src_decoded );
$thumb_url = Video::get_thumbnail( $matched['provider'], $matched['id'], $src_decoded );
$facade_html = Video::build_facade( $load_src, $attrs, $thumb_url );

$search[] = $match[0];
$replace[] = $facade_html;
$replaced = true;
}

if ( $search ) {
$this->content = str_replace( $search, $replace, $this->content );
}

return $replaced;
}

/**
* Parse iframe src.
*
Expand Down
Loading
Loading