(closest thing to native video lazy). * - Auto-fills missing width / height attributes (best CLS win). * - Excludes by substring patterns (src or class match) — useful for * hero banner classes, logo files, etc. * * Tier: Free per FEATURES.md "Images" §1-6 (LiteSpeed parity — all * Free in LS Cache). * * @package XSpeed */ declare(strict_types=1); namespace XSpeed\Modules\Lazy; defined( 'ABSPATH' ) || exit; use XSpeed\Lazy_Loader; use XSpeed\Module; final class LazyModule extends Module { public const SLUG = 'lazy'; public const TIER = self::TIER_FREE; public const VERSION = '1.0.0'; public function ui_metadata(): array { return array( 'label' => __( 'Media Optimization', 'xspeed' ), 'tab_label' => __( 'Lazy Loading', 'xspeed' ), // its own tab on the Media Optimization page 'icon' => 'Image', 'description' => __( 'Loads images, videos and embeds only when visitors scroll to them.', 'xspeed' ), 'group' => 'performance', // Host page: Lazy Loading (this module) + Image Optimization (Pro) // + AI Suggestions (Pro) as tabs — everything a page loads on one // page instead of separate rows (FBS-83633). Fonts is NOT here: it // has its own Optimization card (#86). 'custom_panel' => 'MediaPanel', ); } public function settings_schema(): array { return array( 'lazy_images' => array( 'type' => 'bool', 'default' => true, 'label' => __( 'Lazy-load images', 'xspeed' ), 'description' => __( 'Images load when the visitor scrolls near them. The first image on the page still loads right away.', 'xspeed' ), ), 'lazy_iframes' => array( 'type' => 'bool', 'default' => true, 'label' => __( 'Lazy-load embeds', 'xspeed' ), 'description' => __( 'Embedded content such as YouTube videos and maps loads when the visitor scrolls near it.', 'xspeed' ), ), 'video_facade' => array( 'type' => 'bool', 'default' => false, 'label' => __( 'Click-to-play videos', 'xspeed' ), 'description' => __( 'Shows a preview image with a play button in place of YouTube, Vimeo and your own videos that have a poster image. The video loads only when clicked. Autoplaying videos are left alone.', 'xspeed' ), ), 'lazy_videos' => array( 'type' => 'bool', 'default' => true, 'label' => __( 'Lazy-load your own videos', 'xspeed' ), 'description' => __( 'Videos uploaded to your site download only when played. Autoplaying videos are left alone.', 'xspeed' ), ), 'lazy_background_images' => array( 'type' => 'bool', 'default' => false, 'label' => __( 'Lazy-load background images', 'xspeed' ), 'description' => __( 'Hold back background images set in an element\'s inline style (a Cover or Group block, a page-builder section) until the element is near the screen. Uses a small script; visitors without JavaScript get every background as usual. The first N backgrounds on the page load straight away, like images. Backgrounds set in a stylesheet are not affected.', 'xspeed' ), ), 'eager_first_n' => array( 'type' => 'int', 'default' => 1, 'min' => 0, 'max' => 10, 'label' => __( 'Images to load right away', 'xspeed' ), 'description' => __( 'How many images at the top of the page skip lazy loading. Only the first of them also loads at high priority, and images in hidden or closed sections are not counted. Lazy-loaded background images use the same number. 1 suits most sites; 0 lazy-loads every image.', 'xspeed' ), 'advanced' => true, // Both counters read this number (class-lazy-loader.php), so it // shows while either kind of lazy loading is on. 'dependsOn' => array( 'any' => array( array( 'field' => 'lazy_images' ), array( 'field' => 'lazy_background_images' ), ), ), ), 'add_missing_dimensions' => array( 'type' => 'bool', 'default' => true, 'label' => __( 'Add missing image sizes', 'xspeed' ), 'description' => __( 'Adds width and height to images that lack them, using the media library. This stops the page from jumping as images load.', 'xspeed' ), ), 'excluded_images' => array( 'type' => 'list', 'default' => array(), 'item_type' => 'string', 'label' => __( 'Excluded images', 'xspeed' ), 'description' => __( 'Images and embeds whose tag contains a line here, such as a class or file name, are never lazy-loaded. Useful for logos and hero images.', 'xspeed' ), ), ); } public function conflicts(): array { return array( array( 'plugin' => 'wp-smushit/wp-smush.php', 'feature' => 'images.lazyload', 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN, 'reason' => 'Smush also offers lazy-loading; running both can cause double-rewriting.', ), array( 'plugin' => 'a3-lazy-load/a3-lazy-load.php', 'feature' => 'images.lazyload', 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE, 'reason' => 'a3 Lazy Load is a dedicated lazy-load plugin; disable it before enabling xSpeed lazy-load.', ), ); } public function boot(): void { /* * Deferred to `init`. This module reads its own settings to decide * what to hook, and reading settings builds settings_schema(), whose * labels are declared through __(). boot() runs on `plugins_loaded`, * before `after_setup_theme` — the point WordPress 6.7+ treats as the * earliest safe moment to translate — so doing that here fires * _load_textdomain_just_in_time on every request AND resolves the * labels against a domain that is not loaded yet. * * Everything below hooks actions that fire after `init`, so running * one hook later is equivalent. */ add_action( 'init', array( $this, 'boot_on_init' ) ); } /** * The real boot body — see boot() for why it runs on `init`. */ public function boot_on_init(): void { // Bail entirely on admin / feed / cron / REST — same scope as // Minifier. Lazy-loading rendered HTML only matters on real // frontend page renders. if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } // Never lazy-load inside a builder editor: the builder measures and // positions elements it expects to be loaded. (#281) if ( \XSpeed\Builder_Editor::is_active() ) { return; } $opts = $this->get_settings(); $any_enabled = ! empty( $opts['lazy_images'] ) || ! empty( $opts['lazy_iframes'] ) || ! empty( $opts['lazy_videos'] ) || ! empty( $opts['video_facade'] ) || ! empty( $opts['lazy_background_images'] ) || ! empty( $opts['add_missing_dimensions'] ); if ( ! $any_enabled ) { return; } // Reset the eager-load budget once per page render, before any // content filter runs, so the "first N images eager" budget is // shared across the featured image + content + avatars rather than // restarting on every filter pass. (FBS-82172 Bug 1) add_action( 'template_redirect', array( Lazy_Loader::class, 'reset_state' ) ); // Late priority so the_content runs after every other filter // (shortcodes, do_blocks, embeds). Avoids rewriting tags that // haven't been generated yet. add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 ); add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 ); add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 ); add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 ); // The facade's click handler is printed only on pages that actually // rendered a facade — a page with no embeds should not carry the // script that reveals them. if ( ! empty( $opts['video_facade'] ) ) { add_action( 'wp_footer', array( $this, 'print_facade_script' ), 99 ); // The layout rule goes in the HEAD, unconditionally, while the // handler stays conditional in the footer. Whether a facade // renders isn't known until the content filter has run — long // after wp_head — and a layout rule that arrives in the footer // fixes the gap only after the visitor has already seen it. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) ); // Embeds that never touch the HTML: a builder widget builds its // YouTube iframe from script, so the buffer pass has nothing to // rewrite. Head, priority 1, for the same reason as the autoplay // restorer below — it must be listening before the widget's // script sets a src and commits the fetch. add_action( 'wp_head', array( $this, 'print_observer_script' ), 1 ); } // Head, because the rule that holds a background back has to apply // before first paint, or the browser has already requested it. if ( ! empty( $opts['lazy_background_images'] ) ) { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_background_style' ) ); add_action( 'wp_head', array( $this, 'print_background_script' ), 1 ); } // Same conditional-footer treatment for the autoplay restorer: it is // only printed on a response that actually deferred one. if ( ! empty( $opts['lazy_videos'] ) ) { /* * HEAD, not footer — and as early as anything can run. * * A page-builder video block creates its