From 8faa75a3bb780e858950af8657e8c042bd3a8f37 Mon Sep 17 00:00:00 2001 From: Tim LSC Date: Tue, 12 May 2026 11:11:41 +0300 Subject: [PATCH] Lazy Load Iframes video show image Add functionality - Video Lazyload Add filter for play button --- assets/js/video_facade.js | 101 +++++++++++ assets/js/video_facade.min.js | 1 + autoload.php | 1 + src/base.cls.php | 2 + src/lang.cls.php | 1 + src/media.cls.php | 106 +++++++++++ src/video.cls.php | 255 +++++++++++++++++++++++++++ tpl/page_optm/settings_media.tpl.php | 18 ++ 8 files changed, 485 insertions(+) create mode 100644 assets/js/video_facade.js create mode 100644 assets/js/video_facade.min.js create mode 100644 src/video.cls.php diff --git a/assets/js/video_facade.js b/assets/js/video_facade.js new file mode 100644 index 000000000..ae8d28172 --- /dev/null +++ b/assets/js/video_facade.js @@ -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
, 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(); + } +})(); diff --git a/assets/js/video_facade.min.js b/assets/js/video_facade.min.js new file mode 100644 index 000000000..0037887a2 --- /dev/null +++ b/assets/js/video_facade.min.js @@ -0,0 +1 @@ +(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)}function pseudoReserved(p,pe){var s=getComputedStyle(p,pe);if(!s||s.content==="none")return 0;return(parseFloat(s.paddingTop)||0)+(parseFloat(s.paddingBottom)||0)+(parseFloat(s.height)||0)}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;var w=p.clientWidth||0,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 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 => [], diff --git a/src/lang.cls.php b/src/lang.cls.php index 7ff654c99..0c70bcda3 100644 --- a/src/lang.cls.php +++ b/src/lang.cls.php @@ -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' ), diff --git a/src/media.cls.php b/src/media.cls.php index 93a4f13e5..2fe0ef608 100644 --- a/src/media.cls.php +++ b/src/media.cls.php @@ -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'; /** @@ -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 */ @@ -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 ); @@ -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(); @@ -890,6 +904,10 @@ private function _finalize() { // Include lazyload lib js and init lazyload. if ( $cfg_lazy || $cfg_iframe_lazy ) { $lazy_lib = ''; + if ( $video_facade_used ) { + $lazy_lib .= ''; + $lazy_lib .= ''; + } if ( $cfg_js_delay ) { // Load JS delay lib. if ( ! defined( 'LITESPEED_JS_DELAY_LIB_LOADED' ) ) { @@ -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 #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 `&` / `&` / 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. * diff --git a/src/video.cls.php b/src/video.cls.php new file mode 100644 index 000000000..8e321cf90 --- /dev/null +++ b/src/video.cls.php @@ -0,0 +1,255 @@ +> + */ + public static function providers() { + static $cache = null; + if ( null !== $cache ) { + return $cache; + } + $providers = array( + array( + 'name' => 'youtube', + 'host_match' => array( 'youtube.com', 'youtu.be', 'youtube-nocookie.com' ), + 'id_pattern' => '#(?:v=|/embed/|youtu\.be/)([A-Za-z0-9_-]{6,20})#', + 'thumb_resolver' => 'direct', + 'thumb_url' => 'https://i.ytimg.com/vi/{id}/hqdefault.jpg', + 'oembed_endpoint' => '', + 'autoplay_param' => 'autoplay=1', + ), + array( + 'name' => 'vimeo', + 'host_match' => array( 'vimeo.com', 'player.vimeo.com' ), + 'id_pattern' => '#(?:vimeo\.com/(?:video/)?|player\.vimeo\.com/video/)(\d+)#', + 'thumb_resolver' => 'oembed', + 'thumb_url' => '', + 'oembed_endpoint' => 'https://vimeo.com/api/oembed.json?url=https%3A%2F%2Fvimeo.com%2F{id}', + 'autoplay_param' => 'autoplay=1', + ), + array( + 'name' => 'wistia', + 'host_match' => array( 'wistia.com', 'wistia.net', 'fast.wistia.com', 'fast.wistia.net' ), + 'id_pattern' => '#/(?:medias|embed/iframe|embed/medias)/([A-Za-z0-9]+)#', + 'thumb_resolver' => 'oembed', + 'thumb_url' => '', + 'oembed_endpoint' => 'https://fast.wistia.com/oembed.json?url={url}', + 'autoplay_param' => 'autoPlay=true', + ), + array( + 'name' => 'dailymotion', + 'host_match' => array( 'dailymotion.com', 'dai.ly' ), + 'id_pattern' => '#(?:dailymotion\.com/(?:embed/)?video/|dai\.ly/)([A-Za-z0-9]+)#', + 'thumb_resolver' => 'direct', + 'thumb_url' => 'https://www.dailymotion.com/thumbnail/video/{id}', + 'oembed_endpoint' => '', + 'autoplay_param' => 'autoplay=1', + ), + ); + + $cache = apply_filters( 'litespeed_video_lazy_providers', $providers ); + return $cache; + } + + /** + * Match a URL against the registry. + * + * @param string $url Iframe src URL. + * @return array{provider:array,id:string}|null + */ + public static function extract_provider( $url ) { + if ( ! $url || ! is_string( $url ) ) { + return null; + } + foreach ( self::providers() as $provider ) { + if ( empty( $provider['host_match'] ) || ! is_array( $provider['host_match'] ) || empty( $provider['id_pattern'] ) ) { + continue; + } + foreach ( $provider['host_match'] as $host ) { + if ( false === stripos( $url, $host ) ) { + continue; + } + if ( preg_match( $provider['id_pattern'], $url, $m ) ) { + return array( + 'provider' => $provider, + 'id' => $m[1], + ); + } + } + } + return null; + } + + /** + * Append the autoplay query param to a URL on click activation. + * Preserves any existing #fragment. + * + * @param array $provider Provider record. + * @param string $url Iframe src URL. + * @return string + */ + public static function ensure_autoplay( $provider, $url ) { + $param = $provider['autoplay_param']; + $frag_pos = strpos( $url, '#' ); + $base = false === $frag_pos ? $url : substr( $url, 0, $frag_pos ); + $frag = false === $frag_pos ? '' : substr( $url, $frag_pos ); + + if ( false !== strpos( $base, $param ) ) { + return $url; + } + $sep = ( false === strpos( $base, '?' ) ) ? '?' : '&'; + return $base . $sep . $param . $frag; + } + + /** + * Resolve a thumbnail URL with transient caching. Returns null on miss. + * + * @param array $provider Provider record. + * @param string $id Extracted video ID. + * @param string $original_url Original iframe src (for oEmbed `{url}` substitution). + * @return string|null + */ + public static function get_thumbnail( $provider, $id, $original_url ) { + $key = self::THUMB_TRANSIENT_PREFIX . $provider['name'] . '_' . md5( $id ); + + $cached = get_transient( $key ); + if ( self::THUMB_MISS_SENTINEL === $cached ) { + return apply_filters( 'litespeed_video_lazy_thumbnail', null, $provider['name'], $id ); + } + if ( is_string( $cached ) && $cached ) { + return apply_filters( 'litespeed_video_lazy_thumbnail', $cached, $provider['name'], $id ); + } + + $url = null; + + if ( 'direct' === $provider['thumb_resolver'] ) { + $url = str_replace( '{id}', rawurlencode( $id ), $provider['thumb_url'] ); + } elseif ( 'oembed' === $provider['thumb_resolver'] && ! empty( $provider['oembed_endpoint'] ) ) { + $endpoint = str_replace( + array( '{id}', '{url}' ), + array( rawurlencode( $id ), rawurlencode( $original_url ) ), + $provider['oembed_endpoint'] + ); + $resp = wp_remote_get( $endpoint, array( 'timeout' => self::OEMBED_TIMEOUT ) ); + if ( ! is_wp_error( $resp ) && 200 === wp_remote_retrieve_response_code( $resp ) ) { + $body = json_decode( wp_remote_retrieve_body( $resp ), true ); + if ( is_array( $body ) && ! empty( $body['thumbnail_url'] ) ) { + $url = $body['thumbnail_url']; + } + } + } + + if ( $url ) { + set_transient( $key, $url, self::THUMB_TTL ); + } else { + set_transient( $key, self::THUMB_MISS_SENTINEL, self::THUMB_MISS_TTL ); + } + + return apply_filters( 'litespeed_video_lazy_thumbnail', $url, $provider['name'], $id ); + } + + /** + * Build the click-to-play facade
markup. + * + * The original