PageSourceSearch

https://eerk.ee/wp-content/plugins/janzeman-shared-albums-for-goog…/js/swiper-init.js?ver=2.5.1.1789613681

js eerk.ee collected 2026-10-02 21:36:56 UTC 448,364 bytes, 10,075 lines download raw bytes

1/**
2 * Swiper Gallery Initialization for Shared Albums for Google Photos (by JanZeman)
3 */
4/* global Swiper */
5(function($) {
6'use strict';
7
8	// iOS/iPadOS detection - iPad Pro reports as MacIntel with touch points > 1.
9	var IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
10	    (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
11
12	// Debug flag for optional console logging. Disabled by default.
13	// To enable in the browser console, run: window.JZSA_DEBUG = true;
14	window.JZSA_DEBUG = window.JZSA_DEBUG || false;
15	function jzsaDebug() {
16		if (!window.JZSA_DEBUG) {
17			return;
18        }
19		// eslint-disable-next-line no-console
20		console.log.apply(console, arguments);
21	}
22
23	jzsaDebug('✅ Shared Albums for Google Photos (by JanZeman) loaded');
24
25	function jzsaI18n(key) {
26		return (typeof jzsaAjax !== 'undefined' && jzsaAjax.i18n && jzsaAjax.i18n[key]) || '';
27	}
28
29	function jzsaFormatI18n(key, value) {
30		return jzsaI18n(key).replace('%d', String(value));
31	}
32
33	// Whether to speculatively warm the browser cache with full-resolution images before any
34	// viewer is opened. PHP turns this off in the admin, where no viewer is ever opened.
35	// wp_localize_script casts booleans to strings ("1" and ""), hence the truthiness check
36	// rather than a strict comparison. A missing key means a payload from before this flag
37	// existed, so fall back to preloading and keep the historical behavior.
38	function jzsaShouldPreloadFullImages() {
39		if (typeof jzsaAjax === 'undefined' || typeof jzsaAjax.preloadFullImages === 'undefined') {
40			return true;
41		}
42		return !!jzsaAjax.preloadFullImages;
43	}
44
45	function jzsaEscapeAttr(value) {
46		return String(value)
47			.replace(/&/g, '&')
48			.replace(/"/g, '"')
49			.replace(/</g, '&lt;')
50			.replace(/>/g, '&gt;');
51	}
52
53	/**
54	 * Recolor inline-SVG icon pseudo-elements for a scoped container.
55	 * Data-URI SVGs cannot reference CSS variables, so we inject a scoped
56	 * <style> that rewrites the white fill (%23ffffff) with the chosen color.
57	 */
58	function applyControlsColorToIcons(scopeSelector, color) {
59		var $scope = $(scopeSelector).first();
60		if (!$scope.length) {
61			return;
62		}
63
64		var enc = encodeURIComponent(color);
65		var svgs = {
66			next:         "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 24'><path d='M2 2l8 10-8 10' fill='none' stroke='" + enc + "' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' paint-order='stroke fill'/><path d='M2 2l8 10-8 10' fill='none' stroke='%23000000' stroke-width='4.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M2 2l8 10-8 10' fill='none' stroke='" + enc + "' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/></svg>\")",
67			prev:         "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 24'><path d='M10 2L2 12l8 10' fill='none' stroke='" + enc + "' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' paint-order='stroke fill'/><path d='M10 2L2 12l8 10' fill='none' stroke='%23000000' stroke-width='4.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M10 2L2 12l8 10' fill='none' stroke='" + enc + "' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/></svg>\")",
68			play:         "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M8 5v14l11-7z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")",
69			pause:        "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 18h3V6H7v12zm7-12v12h3V6h-3z' fill='" + enc + "' stroke='%23000000' stroke-width='1' paint-order='stroke fill'/></svg>\")",
70			fullscreen:   "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")",
71			exitFs:       "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")",
72			enterLightbox:"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5zM10.5 10.5
72h3v3h-3z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")",
73			exitLightbox: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3zM10.5 10.5h3v3h-3z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")",
74			link:         "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")",
75			download:     "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z' fill='" + enc + "' stroke='%23000000' stroke-width='1.5' paint-order='stroke fill'/></svg>\")"
76		};
77		var s = scopeSelector;
78		var css =
79			s + ' .swiper-button-next:after{background-image:' + svgs.next + '}' +
80			s + ' .swiper-button-prev:after{background-image:' + svgs.prev + '}' +
81			s + ' .swiper-button-play-pause:after{background-image:' + svgs.play + '}' +
82			s + ' .swiper-button-play-pause.playing:after{background-image:' + svgs.pause + '}' +
83			s + ' .swiper-button-fullscreen:after{background-image:' + svgs.fullscreen + '}' +
84			s + ':fullscreen .swiper-button-fullscreen:after,' +
85			s + ':-webkit-full-screen .swiper-button-fullscreen:after{background-image:' + svgs.exitFs + '}' +
86			s + ' .swiper-button-lightbox:after{background-image:' + svgs.enterLightbox + '}' +
87			s + '[data-lightbox-toggle]:not([data-lightbox-toggle="disabled"]) .jzsa-gallery-thumb-fs-btn:after{background-image:' + svgs.enterLightbox + '}' +
88			s + '.jzsa-gallery-album.jzsa-has-dual-expand .jzsa-gallery-item .jzsa-gallery-thumb-fs-btn.swiper-button-fullscreen:after{background-image:' + svgs.fullscreen + '}' +
89			s + '[data-mode="carousel"].jzsa-has-dual-expand .jzsa-gallery-thumb-fs-btn.swiper-button-fullscreen:after{background-image:' + svgs.fullscreen + '}' +
90			s + '.jzsa-lightbox-active>.jzsa-lightbox-close::after{background-image:' + svgs.exitLightbox + '}' +
91			s + ' .swiper-button-external-link:after{background-image:' + svgs.link + '}' +
92			s + ' .swiper-button-download:after{background-image:' + svgs.download + '}';
93		var styleKey = (scopeSelector || 'scope').replace(/[^a-zA-Z0-9_-]/g, '_');
94		var $style = $scope.children('style[data-jzsa-controls-color="' + styleKey + '"]');
95		if (!$style.length) {
96			$style = $('<style>').attr('data-jzsa-controls-color', styleKey);
97			$scope.append($style);
98		}
99		$style.text(css);
100	}
101
102	var swipers = {};
103	var galleryInitObserver = null;
104    var keyboardOwnerGalleryId = null;
105
106    function isSwiperElementLive(el) {
107        if (!el || !document.body.contains(el)) {
108            return false;
109        }
110        // openLightbox() moves the element into the single shared body-level backdrop and
111        // leaves a comment-node placeholder behind at its original spot; closeLightbox()
112        // uses that placeholder to move it back. A Playground/lazy-preview re-render can
113        // wipe the placeholder's parent (previewContainer.innerHTML = ...) while the element
114        // is still sitting inside the backdrop -- still document.body.contains()-true, but
115        // with nowhere left to be restored to. Once its placeholder is gone, it is orphaned.
116        if (_jzsaLightboxBackdrop && _jzsaLightboxBackdrop.contains(el)) {
117            var placeholder = $(el).data('jzsaLightboxPlaceholder');
118            // parentNode alone is not enough: a whole subtree can be detached from
119            // document (e.g. by previewContainer.innerHTML = ...) while its internal
120            // parent-child links stay intact, so the placeholder's parentNode would
121            // still be non-null even though neither is reachable from the live DOM.
122            return !!(placeholder && document.body.contains(placeholder));
123        }
124        return true;
125    }
126
127    function applyGalleryKeyboardOwner() {
128        Object.keys(swipers).forEach(function(galleryId) {
129            var swiper = swipers[galleryId];
130            var isAttached = swiper && swiper.el && isSwiperElementLive(swiper.el);
131            if (!swiper || swiper.destroyed || !isAttached) {
132                // Playground/lazy-preview re-renders replace a container's innerHTML without
133                // calling swiper.destroy(), so this is the only place a stale entry is noticed.
134                // Left unpruned, every mouseenter/focusin/pointerdown/touchstart on any gallery
135                // -- and every fullscreen/lightbox toggle -- re-scans an ever-growing registry.
136                pruneGalleryKeyboardOwnership(galleryId, swiper);
137                return;
138            }
139            if (!swiper.keyboard) {
140                return;
141            }
142
143            var shouldEnable = galleryId === keyboardOwnerGalleryId && swiper._jzsaKeyboardAllowed;
144            if (shouldEnable && !swiper.keyboard.enabled) {
145                swiper.keyboard.enable();
146            } else if (!shouldEnable && swiper.keyboard.enabled) {
147                swiper.keyboard.disable();
148            }
149        });
150    }
151
152    function pruneGalleryKeyboardOwnership(galleryId, swiper) {
153        if (swiper && !swiper.destroyed) {
154            try { swiper.destroy(true, true); } catch (e) { /* ignore */ }
155        }
156        if (swiper && swiper.el) {
157            try {
158                var $scope = $(swiper.el).closest('.jzsa-gallery-wrapper');
159                if (!$scope.length) {
160                    $scope = $(swiper.el);
161                }
162                $scope.off('.jzsaKeyboardOwner-' + galleryId);
163            } catch (e) { /* ignore */ }
164            // A stale element orphaned inside the shared lightbox backdrop (see
165            // isSwiperElementLive) would otherwise sit there, hidden, forever.
166            if (_jzsaLightboxBackdrop && swiper.el.parentNode === _jzsaLightboxBackdrop) {
167                try { _jzsaLightboxBackdrop.removeChild(swiper.el); } catch (e) { /* ignore */ }
168                if (_jzsaLightboxActiveEl === swiper.el) {
169                    _jzsaLightboxActiveEl = null;
170                }
171            }
172        }
173        delete swipers[galleryId];
174        if (keyboardOwnerGalleryId === galleryId) {
175            keyboardOwnerGalleryId = null;
176        }
177    }
178
179    function claimGalleryKeyboardOwner(galleryId) {
180        if (!galleryId || !swipers[galleryId] || !swipers[galleryId]._jzsaKeyboardAllowed) {
181            return;
182        }
183        keyboardOwnerGalleryId = galleryId;
184        applyGalleryKeyboardOwner();
185    }
186
187    function restoreGalleryKeyboardOwner(galleryId) {
188        if (galleryId && swipers[galleryId] && swipers[galleryId]._jzsaKeyboardAllowed) {
189            keyboardOwnerGalleryId = galleryId;
190        } else {
191            keyboardOwnerGalleryId = null;
192        }
193        applyGalleryKeyboardOwner();
194    }
195
196    function setupGalleryKeyboardOwnership($container, swiper, interactionLock) {
197        if (!$container || !$container.length || !swiper) {
198            return;
199        }
200
201        var galleryId = $container.attr('id');
202        if (!galleryId) {
203            return;
204        }
205
206        swiper._jzsaKeyboardAllowed = !interactionLock;
207        var $scope = $container.closest('.jzsa-gallery-wrapper');
208        if (!$scope.length) {
209            $scope = $container;
210        }
211        var namespace = '.jzsaKeyboardOwner-' + galleryId;
212        $scope.off(namespace);
213        $scope.on(
214            'mouseenter' + namespace +
215            ' focusin' + namespace +
216            ' pointerdown' + namespace +
217            ' touchstart' + namespace,
218            function() {
219                claimGalleryKeyboardOwner(galleryId);
220            }
221        );
222
223        if (!keyboardOwnerGalleryId && !interactionLock && $container.is(':visible')) {
224            keyboardOwnerGalleryId = galleryId;
225        }
226        applyGalleryKeyboardOwner();
227    }
228
229	function ensureGalleryId($gallery, fallbackIndex) {
230		if (!$gallery || !$gallery.length || $gallery.attr('id')) {
231			return;
232		}
233
234		var suffix = fallbackIndex || (Date.now() + '-' + Math.floor(Math.random() * 1000));
235		$gallery.attr('id', 'jzsa-album-' + suffix);
236	}
237
238	function isGalleryInitialized($gallery) {
239		return !!($gallery && $gallery.length && $gallery.attr('data-jzsa-initialized') === 'true');
240	}
241
242	function markGalleryInitialized($gallery) {
243		if ($gallery && $gallery.length) {
244			$gallery.attr('data-jzsa-initialized', 'true');
245		}
246	}
247
248    // Stop and reset all managed videos on the page. Used when switching
249    // between inline and fullscreen so no hidden/orphan playback continues.
250    function stopAllManagedVideos() {
251        $('video.jzsa-video-player').each(function() {
252            var videoEl = this;
253            var $video = $(videoEl);
254            var $album = $video.closest('.jzsa-album, .jzsa-gallery-album');
255
256            // Abort any in-flight loading attempt first.
257            if (typeof videoEl._jzsaCancelLoading === 'function') {
258                try { videoEl._jzsaCancelLoading(); } catch (e) { /* ignore */ }
259            }
260
261            if (videoEl._jzsaPlyr) {
262                videoEl._jzsaSuppressNextError = true;
263                try { videoEl._jzsaPlyr.stop(); } catch (e) { /* ignore */ }
264            } else {
265                try { videoEl.pause(); } catch (e) { /* ignore */ }
266                try { videoEl.currentTime = 0; } catch (e) { /* ignore */ }
267            }
268
269            if ($album.length) {
270                $album.removeClass('jzsa-video-playing');
271                $album.trigger('jzsa:video-stopped');
272            }
273        });
274    }
275
276    // ============================================================================
277    // FULLSCREEN FUNCTIONALITY
278    // ============================================================================
279
280    // Fullscreen toggle function (with iPhone pseudo-fullscreen fallback).
281    // Always enters/exits native (or pseudo) fullscreen; lightbox is handled
282    // separately via toggleLightbox() and the dedicated .swiper-button-lightbox.
283    function toggleFullscreen(element, showHints) {
284        var showHintsFn = showHints;
285        stopAllManagedVideos();
286        // When already in the lightbox, isFullscreen() treats lightbox-active as
287        // fullscreen, which would wrongly prevent entering native fullscreen from
288        // within the lightbox. Use the native API directly for the check instead.
289        var inLightbox = isLightboxActive(element);
290        var nativeFsEl = document.fullscreenElement || document.webkitFullscreenElement ||
291                         document.mozFullScreenElement || document.msFullscreenElement;
292        var currentlyFullscreen = inLightbox
293            ? (nativeFsEl === element || $(element).hasClass('jzsa-pseudo-fullscreen'))
294            : isFullscreen(element);
295        var pseudoActive = $(element).hasClass('jzsa-pseudo-fullscreen');
296
297        if (!currentlyFullscreen) {
298            // On iPhone/iPod, use CSS-based pseudo fullscreen instead of the
299            // Fullscreen API, which is not reliably supported for arbitrary
300            // elements.
301            if (isIphone()) {
302                if (enterPseudoFullscreen(element) && typeof showHintsFn === 'function') {
303                    showHintsFn();
304                }
305                // Native fullscreen events do not fire for pseudo fullscreen.
306                $(element).trigger('jzsa:fullscreen-state', [true]);
307            } else {
308                // Save scroll position so we can restore it when exiting.
309                // Skip when in lightbox - openLightbox already saved the scroll.
310                if (!inLightbox) {
311                    $(element).data('jzsa-scroll-y', window.scrollY || window.pageYOffset);
312                }
313                // Enter native fullscreen where supported
314                if (element.requestFullscreen) {
315                    element.requestFullscreen();
316                } else if (element.webkitRequestFullscreen) {
317                    element.webkitRequestFullscreen();
318                } else if (element.mozRequestFullScreen) {
319                    element.mozRequestFullScreen();
320                } else if (element.msRequestFullscreen) {
321                    element.msRequestFullscreen();
322                }
323
324                // Show hints if enabled
325                if (typeof showHintsFn === 'function') {
326                    showHintsFn();
327                }
328            }
329        } else {
330            if (pseudoActive) {
331                // Exit pseudo fullscreen
332                exitPseudoFullscreen(element);
333                $(element).trigger('jzsa:fullscreen-state', [false]);
334            } else {
335                // Exit native fullscreen
336                if (document.exitFullscreen) {
337                    document.exitFullscreen();
338                } else if (document.webkitExitFullscreen) {
339                    document.webkitExitFullscreen();
340                } else if (document.mozCancelFullScreen) {
341                    document.mozCancelFullScreen();
342                } else if (document.msExitFullscreen) {
343                    document.msExitFullscreen();
344                }
345            }
346        }
347    }
348
349    // ============================================================================
350    // LIGHTBOX (alternative to native fullscreen: a dimmed overlay on top of the
351    // page, showing the album in a bounded box rather than the whole screen).
352    //
353    // Implementation note: the lightbox reuses the existing fullscreen state and
354    // styling. The album element is moved into a fixed full-viewport backdrop and
355    // tagged with `jzsa-lightbox-active jzsa-is-fullscreen`, so all the
356    // fullscreen-mode chrome/behaviour (controls, info boxes, slideshow,
357    // navigation) applies; the lightbox-specific knobs (max width/height, corner
358    // radius, image fit, backdrop colour) sit on top via CSS custom properties.
359    // ============================================================================
360
361    var _jzsaLightboxBackdrop = null;
362    var _jzsaLightboxActiveEl = null;
363    var _jzsaLightboxTriggerEl = null;
364    var _jzsaLightboxKeyHandler = null;
365    // Whether the lightbox was opened via keyboard (Enter/Space) rather than mouse.
366    // Used in closeLightbox to decide whether to restore focus (keyboard a11y only).
367    var _jzsaLightboxOpenedByKeyboard = false;
368    // Last known cursor position, kept in sync by a single global mousemove listener.
369    var _jzsaLastMouseClientX = 0;
370    var _jzsaLastMouseClientY = 0;
371    document.addEventListener('mousemove', function(e) {
372        _jzsaLastMouseClientX = e.clientX;
373        _jzsaLastMouseClientY = e.clientY;
374    }, { passive: true, capture: true });
375
376    // Whether an album opts into the lightbox instead of native fullscreen.
377    function elementUsesLightbox(element) {
378        if (!element || !element.getAttribute) {
379            return false;
380        }
381        var mode = element.getAttribute('data-lightbox-toggle');
382        return !!mode && mode !== 'disabled';
383    }
384
385    function getLightboxBackdrop() {
386        if (_jzsaLightboxBackdrop && document.body.contains(_jzsaLightboxBackdrop)) {
387            return _jzsaLightboxBackdrop;
388        }
389        var bd = document.createElement('div');
390        bd.className = 'jzsa-lightbox-backdrop';
391        bd.setAttribute('role', 'dialog');
392        bd.setAttribute('aria-modal', 'true');
393        bd.setAttribute('aria-label', jzsaI18n('lightboxDialogLabel') || 'Photo viewer');
394        bd.setAttribute('tabindex', '-1');
395        bd.style.display = 'none';
396        document.body.appendChild(bd);
397        _jzsaLightboxBackdrop = bd;
398        return bd;
399    }
400
401    function getSampleCardAnchorId(element) {
402        if (!element) {
403            return '';
404        }
405
406        var card = $(element).closest('.jzsa-sample-card');
407        if (!card.length) {
408            return '';
409        }
410
411        return card.attr('id') || '';
412    }
413
414    function restoreSampleCardAnchor(anchorId) {
415        if (!anchorId) {
416            return;
417        }
418
419        var target = document.getElementById(anchorId);
420        if (!target || !target.scrollIntoView) {
421            return;
422        }
423
424        target.scrollIntoView({ block: 'start', behavior: 'auto' });
425    }
426
427    function isLightboxActive(element) {
428        if (element) {
429            return $(element).hasClass('jzsa-lightbox-active');
430        }
431        return !!_jzsaLightboxActiveEl;
432    }
433
434    function isLightboxPresentationActive(element) {
435        if (!isLightboxActive(element)) {
436            return false;
437        }
438        var nativeFsEl = document.fullscreenElement || document.webkitFullscreenElement ||
439                         document.mozFullScreenElement || document.msFullscreenElement;
440        return nativeFsEl !== element && !$(element).hasClass('jzsa-pseudo-fullscreen');
441    }
442
443    function ensureLightboxCloseButton($el) {
444        var $btn = $el.children('.jzsa-lightbox-close');
445        if (!$btn.length) {
446            $btn = $('<button class="jzsa-lightbox-close" type="button"></button>');
447            var label = jzsaI18n('closeLightbox') || 'Close';
448            $btn.attr({
449                'aria-label': label,
450                'title': label
451            });
452            $btn.on('click', function(e) {
453                e.preventDefault();
454                e.stopPropagation();
455                if (_jzsaLightboxActiveEl) {
456                    closeLightbox(_jzsaLightboxActiveEl);
457                }
458            });
459            $el.append($btn);
460        }
461        return $btn;
462    }
463
464    function openLightbox(element, showHints) {
465        if (!element || isLightboxActive(element)) {
466            return;
467        }
468        var showHintsFn = showHints;
469        if (_jzsaLightboxActiveEl && _jzsaLightboxActiveEl !== element) {
470            closeLightbox(_jzsaLightboxActiveEl);
471        }
472        stopAllManagedVideos();
473
474        var $el = $(element);
475        var backdrop = getLightboxBackdrop();
476        var $backdrop = $(backdrop);
477
478        // Backdrop colour: empty string lets the CSS default (dim black) apply.
479        var bgRaw = $el.attr('data-lightbox-backdrop-color') || '';
480        backdrop.style.background = bgRaw;
481
482        // Remember where to restore the element to (a comment node keeps the spot).
483        var placeholder = document.createComment('jzsa-lightbox-placeholder');
484        if (element.parentNode) {
485            element.parentNode.insertBefore(placeholder, element);
486            $el.data('jzsaLightboxPlaceholder', placeholder);
487        }
488
489        // Remember scroll + make the box opaque (mirrors pseudo-fullscreen).
490        $el.data('jzsa-scroll-y', window.scrollY || window.pageYOffset);
491        $el.data('jzsa-sample-anchor-id', getSampleCardAnchorId(element));
492        $el.data('jzsaLightboxOriginalBg', element.style.getPropertyValue('--gallery-bg-color'));
493        var lbBoxBgRaw = $el.attr('data-lightbox-background-color') || $el.attr('data-background-color') || '';
494        var boxBg = (lbBoxBgRaw && lbBoxBgRaw !== 'transparent') ? lbBoxBgRaw : '#000';
495        element.style.setProperty('--gallery-bg-color', boxBg);
496
497        // Bounded-box caps + corner radius.
498        var maxW = parseInt($el.attr('data-lightbox-max-width'), 10);
499        var maxH = parseInt($el.attr('data-lightbox-max-height'), 10);
500        if (!isNaN(maxW) && maxW > 0) {
501            element.style.setProperty('--jzsa-lightbox-max-width', maxW + 'px');
502        } else {
503            element.style.removeProperty('--jzsa-lightbox-max-width');
504        }
505        if (!isNaN(maxH) && maxH > 0) {
506            element.style.setProperty('--jzsa-lightbox-max-height', maxH + 'px');
507        } else {
508            element.style.removeProperty('--jzsa-lightbox-max-height');
509        }
510        var cornerRadius = parseInt($el.attr('data-lightbox-corner-radius'), 10);
511        if (!isNaN(cornerRadius) && cornerRadius >= 0) {
512            element.style.setProperty('--jzsa-lightbox-corner-radius', cornerRadius + 'px');
513        } else {
514            element.style.removeProperty('--jzsa-lightbox-corner-radius');
515        }
516
517        backdrop.appendChild(element);
518        ensureLightboxCloseButton($el);
519        $el.addClass('jzsa-lightbox-active jzsa-is-fullscreen');
520        $('html, body').addClass('jzsa-no-scroll');
521        $('html').addClass('jzsa-lightbox-open');
522
523        backdrop.style.display = 'flex';
524        // Force a reflow so the opacity transition runs from 0.
525        // eslint-disable-next-line no-unused-expressions
526        backdrop.offsetWidth;
527        $backdrop.addClass('jzsa-lightbox-visible');
528
529        _jzsaLightboxActiveEl = element;
530        _jzsaLightboxTriggerEl = document.activeElement;
531        // Record whether the trigger element was keyboard-focused (not mouse-clicked).
532        // Only restore focus on close when keyboard opened, to prevent :focus-visible
533        // from making gallery thumbnail buttons appear stale after a mouse-driven close.
534        try {
535            _jzsaLightboxOpenedByKeyboard = !!(
536                _jzsaLightboxTriggerEl &&
537                typeof _jzsaLightboxTriggerEl.matches === 'function' &&
538                _jzsaLightboxTriggerEl.matches(':focus-visible')
539            );
540        } catch (e) {
541            _jzsaLightboxOpenedByKeyboard = false;
542        }
543
544        var swiper = swipers[element.id];
545        if (swiper) {
546            swiper._jzsaKeyboardOwnerBeforeLightbox = keyboardOwnerGalleryId;
547            claimGalleryKeyboardOwner(element.id);
548        }
549        if (swiper && $el.attr('data-mode') === 'carousel') {
550            if (swiper._jzsaLightboxOriginalSlidesPerView == null) {
551                swiper._jzsaLightboxOriginalSlidesPerView = swiper.params.slidesPerView;
552                swiper._jzsaLightboxOriginalBreakpoints   = swiper.params.breakpoints;
553                swiper._jzsaLightboxOriginalCenteredSlides = swiper.params.centeredSlides;
554            }
555            swiper.params.slidesPerView = 1;
556            swiper.params.centeredSlides = true;
557            swiper.params.breakpoints = undefined;
558        }
559        if (swiper && typeof swiper.update === 'function') {
560            swiper.update();
561        }
562
563        // Apply lightbox-specific display settings (controls color, navigation, video controls).
564        // Reads data-lightbox-* attrs (pre-resolved from the three-tier hierarchy by PHP).
565        var fsParams = swiper && swiper._jzsaFullscreenParams;
566        if (fsParams) {
567            var lbControlsColor = $el.attr('data-lightbox-controls-color') || '';
568            var lbVideoColor    = $el.attr('data-lightbox-video-controls-color') || '';
569            var lbShowNavAttr   = $el.attr('data-lightbox-show-navigation');
570            var lbShowNav       = lbShowNavAttr !== undefined ? (lbShowNavAttr === 'true') : fsParams.showNavigation;
571            var lbVcaAttr       = $el.attr('data-lightbox-video-controls-autohide');
572            var lbVca           = lbVcaAttr !== undefined ? (lbVcaAttr === 'true') : fsParams.videoControlsAutohide;
573            var lbInfoFontSize  = getInfoFontSizePx($el, 'data-lightbox-info-font-size', fsParams.infoFontSize);
574            var lbInfoFontFamily = getInfoFontFamily($el, 'data-lightbox-info-font-family', fsParams.infoFontFamily);
575            var lbInfoFontColor = getInfoFontColor($el, 'data-lightbox-info-font-color', fsParams.infoFontColor);
576
577            // Build one-shot params object: "fullscreen" slots carry lightbox values.
578            var lbDisplayParams = {
579                galleryId:                    fsParams.galleryId,
580                showNavigation:               fsParams.showNavigation,
581                fullscreenShowNavigation:     lbShowNav,
582                controlsColor:                fsParams.controlsColor,
583                fullscreenControlsColor:      lbControlsColor,
584                videoControlsColor:           fsParams.videoControlsColor,
585                fullscreenVideoControlsColor: lbVideoColor,
586                videoControlsAutohide:        fsParams.videoControlsAutohide,
587                fullscreenVideoControlsAutohide: lbVca,
588                infoFontSize:           fsParams.infoFontSize,
589                fullscreenInfoFontSize: lbInfoFontSize,
590                infoFontFamily:           fsParams.infoFontFamily,
591                fullscreenInfoFontFamily: lbInfoFontFamily,
592                infoFontColor:           fsParams.infoFontColor,
593                fullscreenInfoFontColor: lbInfoFontColor,
594                fullscreenDisplayMaxWidth:  null,
595                fullscreenDisplayMaxHeight: null,
596                fullscreenCornerRadius: null,
597                inlineSlideshowAutoresume:    fsParams.inlineSlideshowAutoresume || fsParams.slideshowAutoresume,
598                fullscreenSlideshowAutoresume: parseSlideshowAutoresumeAttr(
599                    $el.attr('data-lightbox-slideshow-autoresume'),
600                    fsParams.inlineSlideshowAutoresume || fsParams.slideshowAutoresume
601                )
602            };
603            applyFullscreenDisplayOverrides(element, swiper, lbDisplayParams, true);
604            swiper._jzsaLightboxDisplayParams = lbDisplayParams;
605        }
606        if (swiper && swiper._jzsaExpandedMosaicController) {
607            swiper._jzsaExpandedMosaicController.useLightbox();
608        }
609
610        // Start lightbox slideshow if configured.
611        if (swiper && fsParams && fsParams.lightboxSlideshow !== 'disabled') {
612            if (swiper.autoplay && swiper.autoplay.running) { swiper.autoplay.stop(); }
613            var lbOpenDelay = fsParams.lightboxSlideshowDelay * MILLISECONDS_PER_SECOND;
614            swiper.params.autoplay.delay = lbOpenDelay;
615            if (swiper.autoplay) { swiper.autoplay.delay = lbOpenDelay; }
616            if (!fsParams.slideshowPausedByInteraction && fsParams.lightboxSlideshow === 'auto') {
617                swiper.autoplay.start();
618            }
619        }
620
621        $el.trigger('jzsa:fullscreen-state', [true]);
622        if (typeof showHintsFn === 'function') {
623            showHintsFn();
624        }
625
626        _jzsaLightboxKeyHandler = function(e) {
627            if (e.key === 'Escape' || e.keyCode === 27) {
628                // When in native fullscreen from within the lightbox, let the
629                // browser handle Esc; handleFullscreenChange will return us to
630                // the lightbox rather than closing it entirely.
631                var nativeFsEl = document.fullscreenElement || document.webkitFullscreenElement ||
632                                 document.mozFullScreenElement || document.msFullscreenElement;
633                if (nativeFsEl === element) {
634                    return;
635                }
636                e.preventDefault();
637                closeLightbox(element);
638                return;
639            }
640            if (e.key === 'Tab') {
641                var focusable = Array.prototype.slice.call(
642                    backdrop.querySelectorAll('button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])')
643                ).filter(function(el) { return el.offsetParent !== null; });
644                if (focusable.length < 2) { e.preventDefault(); return; }
645                var first = focusable[0];
646                var last = focusable[focusable.length - 1];
647                if (e.shiftKey) {
648                    if (document.activeElement === first) { e.preventDefault(); last.focus(); }
649                } else {
650                    if (document.activeElement === last) { e.preventDefault(); first.focus(); }
651                }
652            }
653        };
654        document.addEventListener('keydown', _jzsaLightboxKeyHandler, true);
655        $backdrop.off('click.jzsaLightbox').on('click.jzsaLightbox', function(e) {
656            if (e.target === backdrop) {
657                var nativeFsEl = document.fullscreenElement || document.webkitFullscreenElement ||
658                                 document.mozFullScreenElement || document.msFullscreenElement;
659                if (nativeFsEl === element) {
660                    return;
661                }
662                closeLightbox(element);
663            }
664        });
665
666        try {
667            backdrop.focus();
668        } catch (err) { /* ignore */ }
669    }
670
671    function closeLightbox(element) {
672        if (!element || !isLightboxActive(element)) {
673            return;
674        }
675        stopAllManagedVideos();
676
677        var $el = $(element);
678        var backdrop = _jzsaLightboxBackdrop;
679
680        $el.removeClass('jzsa-lightbox-active jzsa-is-fullscreen');
681        if (backdrop) {
682            $(backdrop).removeClass('jzsa-lightbox-visible').off('click.jzsaLightbox');
683        }
684        if (_jzsaLightboxKeyHandler) {
685            document.removeEventListener('keydown', _jzsaLightboxKeyHandler, true);
686            _jzsaLightboxKeyHandler = null;
687        }
688
689        // Move the element back to where it came from.
690        var placeholder = $el.data('jzsaLightboxPlaceholder');
691        if (placeholder && placeholder.parentNode) {
692            placeholder.parentNode.insertBefore(element, placeholder);
693            placeholder.parentNode.removeChild(placeholder);
694        }
695        $el.removeData('jzsaLightboxPlaceholder');
696
697        var origBg = $el.data('jzsaLightboxOriginalBg');
698        if (origBg) {
699            element.style.setProperty('--gallery-bg-color', origBg);
700        } else {
701            element.style.removeProperty('--gallery-bg-color');
702        }
703        $el.removeData('jzsaLightboxOriginalBg');
704        element.style.removeProperty('--jzsa-lightbox-max-width');
705        element.style.removeProperty('--jzsa-lightbox-max-height');
706        element.style.removeProperty('--jzsa-lightbox-corner-radius');
707
708        $('html, body').removeClass('jzsa-no-scroll');
709        $('html').removeClass('jzsa-lightbox-open');
710        if (backdrop) {
711            backdrop.style.display = 'none';
712        }
713
714        // Force the browser to re-evaluate :hover after the backdrop disappears.
715        // Without this, the gallery item under the cursor position can stay "stuck"
716        // in :hover state because browsers only update hover on pointer movement.
717        try {
718            document.dispatchEvent(new MouseEvent('mousemove', {
719                bubbles: true,
720                cancelable: true,
721                view: window,
722                clientX: _jzsaLastMouseClientX,
723                clientY: _jzsaLastMouseClientY
724            }));
725        } catch (e) { /* ignore */ }
726
727        var swiper = swipers[element.id];
728        var keyboardOwnerBeforeLightbox = swiper ? swiper._jzsaKeyboardOwnerBeforeLightbox : null;
729        if (swiper && swiper._jzsaLightboxOriginalSlidesPerView != null) {
730            var targetIndex = (typeof swiper.realIndex === 'number') ? swiper.realIndex : swiper.activeIndex;
731            swiper.params.slidesPerView  = swiper._jzsaLightboxOriginalSlidesPerView;
732            swiper.params.centeredSlides = swiper._jzsaLightboxOriginalCenteredSlides;
733            swiper.params.breakpoints    = swiper._jzsaLightboxOriginalBreakpoints;
734            swiper._jzsaLightboxOriginalSlidesPerView  = null;
735            swiper._jzsaLightboxOriginalBreakpoints    = null;
736            swiper._jzsaLightboxOriginalCenteredSlides = null;
737            if (swiper && typeof swiper.update === 'function') {
738                swiper.update();
739            }
740            if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
741                swiper.slideToLoop(targetIndex, 0, false);
742            } else {
743                swiper.slideTo(targetIndex, 0, false);
744            }
745        } else if (swiper && typeof swiper.update === 'function') {
746            swiper.update();
747        }
748
749        // Restore inline display settings (controls color, navigation, video controls).
750        var fsParams = swiper && swiper._jzsaFullscreenParams;
751        if (fsParams) {
752            applyFullscreenDisplayOverrides(element, swiper, fsParams, false);
753            swiper._jzsaLightboxDisplayParams = null;
754        }
755        if (swiper && swiper._jzsaExpandedMosaicController) {
756            swiper._jzsaExpandedMosaicController.useFullscreen();
757        }
758
759        // Stop lightbox slideshow and restore inline slideshow if it was running.
760        if (swiper && fsParams) {
761            if (swiper.autoplay && swiper.autoplay.running) { swiper.autoplay.stop(); }
762            if (fsParams.slideshow === 'auto' && !fsParams.slideshowPausedByInteraction) {
763                var lbCloseNormalDelay = fsParams.slideshowDelay * MILLISECONDS_PER_SECOND;
764                swiper.params.autoplay.delay = lbCloseNormalDelay;
765                if (swiper.autoplay) { swiper.autoplay.delay = lbCloseNormalDelay; }
766                swiper.autoplay.start();
767            }
768        }
769
770        $el.trigger('jzsa:fullscreen-state', [false]);
771        notifyGalleryOnFullscreenExit(element, swiper);
772
773        var sampleAnchorId = $el.data('jzsa-sample-anchor-id');
774        if (sampleAnchorId) {
775            window.requestAnimationFrame(function () {
776                window.requestAnimationFrame(function () {
777                    restoreSampleCardAnchor(sampleAnchorId);
778                });
779            });
780        }
781        $el.removeData('jzsa-sample-anchor-id');
782
783        _jzsaLightboxActiveEl = null;
784        if (swiper) {
785            delete swiper._jzsaKeyboardOwnerBeforeLightbox;
786            if (keyboardOwnerBeforeLightbox && keyboardOwnerBeforeLightbox !== element.id) {
787                restoreGalleryKeyboardOwner(keyboardOwnerBeforeLightbox);
788            } else if ($el.is(':visible')) {
789                claimGalleryKeyboardOwner(element.id);
790            } else {
791                restoreGalleryKeyboardOwner(null);
792            }
793        }
794        // Only restore focus when the lightbox was opened via keyboard. For mouse-
795        // opened lightboxes, restoring focus causes :focus-visible to appear on the
796        // gallery thumbnail button, making it look stale after close.
797        if (_jzsaLightboxOpenedByKeyboard && _jzsaLightboxTriggerEl && typeof _jzsaLightboxTriggerEl.focus === 'function') {
798            try {
799                _jzsaLightboxTriggerEl.focus();
800            } catch (err) { /* ignore */ }
801        }
802        _jzsaLightboxTriggerEl = null;
803        _jzsaLightboxOpenedByKeyboard = false;
804    }
805
806    function toggleLightbox(element, showHints) {
807        if (isLightboxActive(element)) {
808            closeLightbox(element);
809        } else {
810            openLightbox(element, showHints);
811        }
812    }
813
814    // ============================================================================
815    // HINT SYSTEM
816    // ============================================================================
817
818    // Show gesture hints only the first few times, with a global counter across all albums.
819    // To reset fullscreen hints for testing: localStorage.removeItem('jzsa-hints-counter')
820    // To reset lightbox hints for testing: localStorage.removeItem('jzsa-lightbox-hints-counter')
821    function createHintSystem(galleryId, storageKey, hintLines) {
822        var HINTS_STORAGE_KEY = storageKey || 'jzsa-hints-counter'; // Global counter for all albums
823        var MAX_HINT_DISPLAYS = 1; // Maximum number of times to show hints
824        var HINT_FADE_IN_DELAY = 100; // ms
825        var HINT_FADE_OUT_DELAY = 500; // ms
826        var HINT_DISPLAY_DURATION = 9500; // ms - how long hint stays visible
827        var DEFAULT_HINTS = [
828            'Click / tap / swipe left or right to browse photos',
829            'Press Esc or tap \u29C9 to exit fullscreen'
830        ];
831        var configuredHints = hintLines && hintLines.length ? hintLines : DEFAULT_HINTS;
832
833        return function showHints() {
834            // Check if we should show hints
835            try {
836                var hintCount = parseInt(localStorage.getItem(HINTS_STORAGE_KEY) || '0');
837
838                if (hintCount >= MAX_HINT_DISPLAYS) {
839                    return; // Already shown enough times
840                }
841
842                // Increment counter
843                localStorage.setItem(HINTS_STORAGE_KEY, String(hintCount + 1));
844
845                // Build hint message
846                var hints = configuredHints.slice();
847
848                if (hints.length === 0) {
849                    return; // No hints to show
850                }
851
852                // Create hint overlay - use line breaks instead of bullets
853                var hintText = hints.join('<br>');
854                var $hint = $('<div class="jzsa-hint">' + hintText + '</div>');
855                $('#' + galleryId).append($hint);
856
857                // Fade in
858                setTimeout(function() {
859                    $hint.addClass('jzsa-hint-visible');
860                }, HINT_FADE_IN_DELAY);
861
862                // Fade out after some seconds
863                setTimeout(function() {
864                    $hint.removeClass('jzsa-hint-visible');
865                    setTimeout(function() {
866                        $hint.remove();
867                    }, HINT_FADE_OUT_DELAY);
868                }, HINT_DISPLAY_DURATION);
869
870			} catch (e) {
871				// localStorage might not be available (private browsing, etc.)
872				jzsaDebug('Hints not available:', e);
873			}
874		};
875    }
876
877    // ============================================================================
878    // HELPER FUNCTIONS
879    // ============================================================================
880
881    // Time conversion constant (shared by all helpers and initializers)
882    var MILLISECONDS_PER_SECOND = 1000; // Conversion factor from seconds to milliseconds
883    // Loader UX: show immediate visual feedback, then progressively reveal copy.
884    var LOADER_TEXT_SHOW_DELAY_MS = 1400;
885    var LOADER_SLOW_MESSAGE_DELAY_MS = 8000;
886    var LOADER_MIN_VISIBLE_MS = 250;
887
888    // Helper: Run after the next paint cycle (double RAF) to avoid synchronous
889    // layout flushes when restarting CSS transitions.
890    function runOnNextPaint(callback) {
891        var raf = window.requestAnimationFrame || function(cb) { return window.setTimeout(cb, 16); };
892        raf(function() {
893            raf(function() {
894                callback();
895            });
896        });
897    }
898
899    // Helper: Prefer transitionend, with a timeout fallback for safety.
900    function waitForTransitionEnd($element, fallbackMs, callback) {
901        var done = false;
902        function finish() {
903            if (done) {
904                return;
905            }
906            done = true;
907            if ($element && $element.length) {
908                $element.off('.jzsaTransitionWait');
909            }
910            callback();
911        }
912
913        if ($element && $element.length) {
914            $element.on('transitionend.jzsaTransitionWait webkitTransitionEnd.jzsaTransitionWait', function(event) {
915                if (!event || event.target !== $element[0]) {
916                    return;
917                }
918                finish();
919            });
920        }
921
922        window.setTimeout(finish, fallbackMs);
923    }
924
925    // Helper: Detect Android (for platform-specific workarounds)
926    function isAndroid() {
927        var ua = window.navigator.userAgent || '';
928        return /Android/i.test(ua);
929    }
930
931    // Helper: Detect iOS devices (iPhone, iPad, iPod, and iPadOS with Mac-like UA)
932    // Used for pseudo fullscreen fallback and for old-iOS layout workarounds.
933    function isIosDevice() {
934        var ua = window.navigator.userAgent || '';
935        var isAppleMobile = /iPad|iPhone|iPod/i.test(ua);
936        var isTouchMac = /Macintosh/i.test(ua) && window.navigator.maxTouchPoints > 1;
937        return isAppleMobile || isTouchMac;
938    }
939
940    // Helper: Detect touch-capable devices for pinch gestures.
941    // We only keep zoom enabled on touch input to avoid desktop
942    // mouse/double-click zoom conflicts with fullscreen gestures.
943    function hasTouchInput() {
944        if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) {
945            return true;
946        }
947        return (window.navigator.maxTouchPoints || 0) > 0;
948    }
949
950    // Helper: Detect iPhone/iPod (used for pseudo fullscreen fallback)
951    function isIphone() {
952        var ua = window.navigator.userAgent || '';
953        return /iPhone|iPod/i.test(ua);
954    }
955
956    // Helper: Check if currently in fullscreen (native or pseudo)
957    function isFullscreen(targetElement) {
958        var nativeElement = document.fullscreenElement || document.webkitFullscreenElement ||
959                  document.mozFullScreenElement || document.msFullscreenElement;
960
961        if (nativeElement) {
962            return targetElement ? nativeElement === targetElement : true;
963        }
964
965        if (targetElement) {
966            return $(targetElement).hasClass('jzsa-pseudo-fullscreen') || $(targetElement).hasClass('jzsa-lightbox-active');
967        }
968
969        return $('.jzsa-pseudo-fullscreen, .jzsa-lightbox-active').length > 0;
970    }
971
972    // Helper: Enter/exit pseudo fullscreen (CSS-driven fallback for iPhone)
973    // Paints page background black to blend with browser chrome (YouTube-like).
974    var _savedHtmlBg = '';
975    var _savedBodyBg = '';
976
977    function enterPseudoFullscreen(element) {
978        if (!element) {
979            return false;
980        }
981        var $el = $(element);
982        if ($el.hasClass('jzsa-pseudo-fullscreen')) {
983            return true;
984        }
985
986        // Save scroll position for restoration on exit
987        $el.data('jzsa-scroll-y', window.scrollY || window.pageYOffset);
988
989        // Paint page background to match fullscreen so browser chrome blends in
990        _savedHtmlBg = document.documentElement.style.backgroundColor;
991        _savedBodyBg = document.body.style.backgroundColor;
992        var fsBgRaw = $el.attr('data-fullscreen-background-color') ||
993                      $el.attr('data-background-color') || '';
994        var fsBg = (fsBgRaw && fsBgRaw !== 'transparent') ? fsBgRaw : '#000';
995        document.documentElement.style.backgroundColor = fsBg;
996        document.body.style.backgroundColor = fsBg;
997
998        // Apply fullscreen background to the gallery element itself.
999        // The CSS variable --gallery-bg-color may be 'transparent' for inline
1000        // mode; override it so the fixed container is fully opaque.
1001        $el.data('jzsa-pseudo-fs-original-bg', element.style.getPropertyValue('--gallery-bg-color'));
1002        element.style.setProperty('--gallery-bg-color', fsBg);
1003
1004        $el.addClass('jzsa-pseudo-fullscreen jzsa-is-fullscreen');
1005        $('html, body').addClass('jzsa-no-scroll');
1006        return true;
1007    }
1008
1009    function exitPseudoFullscreen(element) {
1010        if (!element) {
1011            return;
1012        }
1013        var $el = $(element);
1014
1015        // Restore page background
1016        document.documentElement.style.backgroundColor = _savedHtmlBg;
1017        document.body.style.backgroundColor = _savedBodyBg;
1018
1019        // Restore original --gallery-bg-color
1020        var originalBg = $el.data('jzsa-pseudo-fs-original-bg');
1021        if (originalBg !== undefined) {
1022            if (originalBg) {
1023                element.style.setProperty('--gallery-bg-color', originalBg);
1024            } else {
1025                element.style.removeProperty('--gallery-bg-color');
1026            }
1027            $el.removeData('jzsa-pseudo-fs-original-bg');
1028        }
1029
1030        $el.removeClass('jzsa-pseudo-fullscreen jzsa-is-fullscreen');
1031        $('html, body').removeClass('jzsa-no-scroll');
1032
1033        notifyGalleryOnFullscreenExit(element, swipers[element.id]);
1034    }
1035
1036    // Helper: Restore page scroll and notify the gallery to navigate to the
1037    // last-viewed photo after any fullscreen exit (native or pseudo).
1038    // A single shared function ensures both exit paths behave identically
1039    // and cannot diverge.
1040    //
1041    // @param {Element} element  The slideshow DOM element that was in fullscreen.
1042    // @param {Object}  swiper   The Swiper instance for that element.
1043    function notifyGalleryOnFullscreenExit(element, swiper) {
1044        var $el = $(element);
1045        var savedY = $el.data('jzsa-scroll-y');
1046
1047        // Only act if this element was the one that entered fullscreen.
1048        // savedY is set exclusively on FS entry; its absence means this
1049        // handler fired for a gallery that was never in fullscreen (e.g. a
1050        // second gallery on the same page whose fullscreenchange listener
1051        // fired as a bystander when another gallery exited).
1052        if (savedY == null) {
1053            return;
1054        }
1055
1056        window.scrollTo(0, savedY);
1057        $el.removeData('jzsa-scroll-y');
1058
1059        if ($el.hasClass('jzsa-gallery-slideshow') && swiper) {
1060            var currentIndex = (typeof swiper.realIndex === 'number') ? swiper.realIndex : swiper.activeIndex;
1061            var galleryId = element.id.replace(/-slideshow$/, '');
1062            $('#' + galleryId).trigger('jzsa:focus-index', [currentIndex]);
1063        }
1064    }
1065
1066    // Helper: Check if click should be ignored (clicked on UI element)
1067    function shouldIgnoreClick(target) {
1068        return $(target).closest('.swiper-button-next, .swiper-button-prev, .swiper-button-fullscreen, .swiper-button-lightbox, .swiper-button-external-link, .swiper-button-download, .swiper-button-play-pause, .swiper-pagination, .plyr__controls, .plyr__control').length > 0;
1069    }
1070
1071    // Helper: Read gallery mode data attributes.
1072    function readGalleryAttr($container, suffix) {
1073        return $container.attr('data-gallery-' + suffix);
1074    }
1075
1076    // Helper: Read a boolean data attribute with fallback.
1077    function readBooleanDataAttr($container, attrName, fallback) {
1078        var value = $container.attr(attrName);
1079        if (value === undefined) {
1080            return !!fallback;
1081        }
1082        return value === 'true';
1083    }
1084
1085    // Helper: Read an optional positive integer data attribute.
1086    function readOptionalPositiveIntAttr($container, attrName) {
1087        var rawValue = $container.attr(attrName);
1088        if (rawValue === undefined || rawValue === null || rawValue === '') {
1089            return null;
1090        }
1091
1092        var parsed = parseInt(rawValue, 10);
1093        if (isNaN(parsed) || parsed <= 0) {
1094            return null;
1095        }
1096
1097        return parsed;
1098    }
1099
1100    // Helper: Read an optional non-negative integer data attribute.
1101    function readOptionalNonNegativeIntAttr($container, attrName) {
1102        var rawValue = $container.attr(attrName);
1103        if (rawValue === undefined || rawValue === null || rawValue === '') {
1104            return null;
1105        }
1106
1107        var parsed = parseInt(rawValue, 10);
1108        return isNaN(parsed) || parsed < 0 ? null : parsed;
1109    }
1110
1111    // Helper: Parse per-gallery download warning size from data attr (MB -> bytes).
1112    // Returns null when not specified, 0 to disable warning.
1113    function getDownloadWarningSizeBytes($container) {
1114        if (!$container || !$container.length) {
1115            return null;
1116        }
1117
1118        var rawValue = $container.attr('data-download-size-warning');
1119        if (rawValue === undefined || rawValue === null || rawValue === '') {
1120            return null;
1121        }
1122
1123        var parsedMb = parseInt(rawValue, 10);
1124        if (isNaN(parsedMb) || parsedMb < 0) {
1125            return null;
1126        }
1127
1128        if (parsedMb === 0) {
1129            return 0;
1130        }
1131
1132        return parsedMb * 1024 * 1024;
1133    }
1134
1135    // Helper: Parse slideshow autoresume data attribute.
1136    function parseSlideshowAutoresumeAttr(rawValue, fallbackValue) {
1137        var fallback = (fallbackValue === undefined || fallbackValue === null || fallbackValue === '') ? 30 : fallbackValue;
1138        if (rawValue === undefined || rawValue === null || rawValue === '') {
1139            return fallback;
1140        }
1141
1142        if (String(rawValue).toLowerCase() === 'disabled') {
1143            return 'disabled';
1144        }
1145
1146        var parsed = parseInt(rawValue, 10);
1147        if (!isNaN(parsed) && parsed > 0) {
1148            return parsed;
1149        }
1150
1151        return fallback;
1152    }
1153
1154    // Helper: Write gallery mode data attributes.
1155    function writeGalleryAttr($container, suffix, value) {
1156        $container.attr('data-gallery-' + suffix, value);
1157    }
1158
1159    // Helper: Double-tap detection for touch devices
1160    var DOUBLE_TAP_MAX_DELAY = 250; // ms - maximum time between taps to be considered a double-tap
1161    var lastTap = 0;
1162    function handleDoubleTap(e, callback) {
1163        var currentTime = new Date().getTime();
1164        var tapLength = currentTime - lastTap;
1165
1166        if (tapLength < DOUBLE_TAP_MAX_DELAY && tapLength > 0) {
1167            e.preventDefault();
1168            callback(e);
1169            lastTap = 0;
1170        } else {
1171            lastTap = currentTime;
1172        }
1173    }
1174
1175    // Trace tag for video auto-healing flow (use in browser console filter).
1176    var VIDEO_HEAL_TRACE_TAG = '[JZSA_HEAL_TRACE]';
1177    // Keep console trace focused strictly on heal attempts and outcomes.
1178    var VIDEO_HEAL_TRACE_EVENTS = {
1179        'heal-start': true,
1180        'heal-refresh-applied': true,
1181        'heal-refresh-failed': true,
1182        'heal-play-rejected': true,
1183        'playback-failed': true
1184    };
1185
1186    function shortMediaUrl(url) {
1187        if (!url) {
1188            return '';
1189        }
1190        if (url.length <= 96) {
1191            return url;
1192        }
1193        return url.slice(0, 56) + '…' + url.slice(-24);
1194    }
1195
1196    function traceVideoHeal(eventName, payload) {
1197        if (!VIDEO_HEAL_TRACE_EVENTS[eventName]) {
1198            return;
1199        }
1200        if (!window.console || typeof window.console.log !== 'function') {
1201            return;
1202        }
1203        try {
1204            console.log(VIDEO_HEAL_TRACE_TAG + ' ' + eventName, payload || {});
1205        } catch (e) {
1206            // Ignore logging failures.
1207        }
1208    }
1209
1210    /**
1211     * Build the inner HTML for a video item (wrapper + video element).
1212     * Shared across player, carousel, and gallery modes.
1213     *
1214     * @param {Object} opts
1215     * @param {string} opts.src        Video source URL.
1216     * @param {number} [opts.mediaIndex] Stable index in the full album array.
1217     * @param {string} [opts.extraClass] Additional classes on the <video> element.
1218     * @param {string} [opts.style]      Inline style attribute value for the <video>.
1219     * @return {string} HTML string.
1220     */
1221    function buildVideoHtml(opts) {
1222        var src = opts.src || '';
1223        var poster = opts.poster || '';
1224        var extraClass = opts.extraClass ? ' ' + opts.extraClass : '';
1225        var styleAttr = opts.style ? ' style="' + opts.style + '"' : '';
1226        var posterAttr = poster ? ' poster="' + poster + '"' : '';
1227        var mediaIndexAttr = (typeof opts.mediaIndex === 'number' && opts.mediaIndex >= 0)
1228            ? ' data-jzsa-media-index="' + opts.mediaIndex + '"'
1229            : '';
1230        return '<div class="jzsa-video-wrapper">' +
1231            '<video' +
1232            ' src="' + src + '"' +
1233            posterAttr +
1234            ' playsinline preload="none" disablepictureinpicture referrerpolicy="no-referrer"' +
1235            ' class="jzsa-video-player' + extraClass + '"' +
1236            mediaIndexAttr +
1237            styleAttr +
1238            '></video>' +
1239            '</div>';
1240    }
1241
1242    /**
1243     * Re-fetch fresh media URLs from Google Photos when video URLs expire (HTTP 400).
1244     * One in-flight request per album container; returns a promise.
1245     */
1246    var _refreshPromises = {};
1247
1248    function applyFreshMediaToContainer($albumContainer, freshPhotos, options) {
1249        var opts = options || {};
1250        var triggerVideoEl = opts.triggerVideoEl || null;
1251        var reason = opts.reason || 'unknown';
1252        var forceTriggerReload = !!opts.forceTriggerReload;
1253        var freshVideos = freshPhotos.filter(function(p) { return p.type === 'video'; });
1254        var fallbackVideoCursor = 0;
1255        var triggerUpdated = false;
1256        var triggerSeenInMapping = false;
1257
1258        // Update data attribute so future renders use fresh URLs.
1259        $albumContainer.attr('data-all-photos', JSON.stringify(freshPhotos));
1260
1261        $albumContainer.find('video.jzsa-video-player').each(function() {
1262            var videoEl = this;
1263            var mediaIndex = parseInt(videoEl.getAttribute('data-jzsa-media-index') || '', 10);
1264            var freshEntry = null;
1265
1266            if (!isNaN(mediaIndex) && mediaIndex >= 0 && mediaIndex < freshPhotos.length) {
1267                var candidate = freshPhotos[mediaIndex];
1268                if (candidate && candidate.type === 'video') {
1269                    freshEntry = candidate;
1270                }
1271            }
1272            if (!freshEntry && fallbackVideoCursor < freshVideos.length) {
1273                freshEntry = freshVideos[fallbackVideoCursor++];
1274            }
1275            if (!freshEntry || !freshEntry.video) {
1276                traceVideoHeal('refresh-map-skip', {
1277                    reason: reason,
1278                    containerId: $albumContainer.attr('id') || '',
1279                    mediaIndex: isNaN(mediaIndex) ? null : mediaIndex
1280                });
1281                return;
1282            }
1283
1284            var oldSrc = videoEl.getAttribute('src') || videoEl.src || '';
1285            $(videoEl).attr('src', freshEntry.video);
1286            if (freshEntry.preview) {
1287                $(videoEl).attr('poster', freshEntry.preview);
1288            }
1289            var srcChanged = oldSrc !== freshEntry.video;
1290
1291            var isTrigger = !!(triggerVideoEl && videoEl === triggerVideoEl);
1292            if (isTrigger) {
1293                triggerSeenInMapping = true;
1294            }
1295            traceVideoHeal('refresh-map', {
1296                reason: reason,
1297                containerId: $albumContainer.attr('id') || '',
1298                mediaIndex: isNaN(mediaIndex) ? null : mediaIndex,
1299                trigger: isTrigger,
1300                srcChanged: srcChanged,
1301                oldSrc: shortMediaUrl(oldSrc),
1302                newSrc: shortMediaUrl(freshEntry.video)
1303            });
1304
1305            // Only force reload for the video that is currently recovering,
1306            // and only when the URL actually changed.
1307            if (isTrigger && (srcChanged || forceTriggerReload)) {
1308                triggerUpdated = true;
1309                videoEl.load();
1310            }
1311        });
1312
1313        if (triggerVideoEl && !triggerUpdated && !triggerSeenInMapping && document.documentElement.contains(triggerVideoEl)) {
1314            traceVideoHeal('refresh-trigger-fallback-load', {
1315                reason: reason,
1316                containerId: $albumContainer.attr('id') || '',
1317                mediaIndex: triggerVideoEl.getAttribute('data-jzsa-media-index') || null
1318            });
1319            triggerVideoEl.load();
1320        }
1321    }
1322
1323    function refreshAlbumUrls($albumContainer, options) {
1324        var opts = options || {};
1325        var reason = opts.reason || 'unknown';
1326        var triggerVideoEl = opts.triggerVideoEl || null;
1327        var forceNetwork = !!opts.forceNetwork;
1328        var forceTriggerReload = !!opts.forceTriggerReload;
1329        var shouldApply = typeof opts.shouldApply === 'function' ? opts.shouldApply : null;
1330
1331        // Try the container itself, then walk up to find data-album-url
1332        // (e.g. gallery slideshow is a sibling of the gallery container).
1333        var albumUrl = $albumContainer.attr('data-album-url') || '';
1334        if (!albumUrl) {
1335            var $parent = $albumContainer.closest('[data-album-url]');
1336            if (!$parent.length) {
1337                $parent = $albumContainer.siblings('[data-album-url]');
1338            }
1339            albumUrl = $parent.attr('data-album-url') || '';
1340        }
1341        if (!albumUrl || typeof jzsaAjax === 'undefined') {
1342            traceVideoHeal('refresh-skip-missing-context', {
1343                reason: reason,
1344                containerId: $albumContainer.attr('id') || '',
1345                hasAlbumUrl: !!albumUrl,
1346                hasAjax: typeof jzsaAjax !== 'undefined'
1347            });
1348            return $.Deferred().reject().promise();
1349        }
1350
1351        // Deduplicate network calls per album URL; each caller still applies
1352        // the fresh response to its own container.
1353        if (!forceNetwork && _refreshPromises[albumUrl]) {
1354            traceVideoHeal('refresh-reuse-inflight', {
1355                reason: reason,
1356                albumUrl: shortMediaUrl(albumUrl),
1357                containerId: $albumContainer.attr('id') || ''
1358            });
1359            return _refreshPromises[albumUrl].then(function(freshPhotos) {
1360                if (!shouldApply || shouldApply()) {
1361                    applyFreshMediaToContainer($albumContainer, freshPhotos, {
1362                        reason: reason,
1363                        triggerVideoEl: triggerVideoEl,
1364                        forceTriggerReload: forceTriggerReload
1365                    });
1366                }
1367                return freshPhotos;
1368            });
1369        }
1370
1371        traceVideoHeal('refresh-fetch-start', {
1372            reason: reason,
1373            albumUrl: shortMediaUrl(albumUrl),
1374            containerId: $albumContainer.attr('id') || '',
1375            triggerMediaIndex: triggerVideoEl ? triggerVideoEl.getAttribute('data-jzsa-media-index') || null : null
1376        });
1377
1378        var refreshRequest = $.ajax({
1379            url: jzsaAjax.ajaxUrl,
1380            type: 'POST',
1381            data: {
1382                action: 'jzsa_refresh_urls',
1383                nonce: jzsaAjax.refreshNonce,
1384                album_url: albumUrl,
1385                force_refresh: forceNetwork ? 1 : 0
1386            }
1387        }).then(function(response) {
1388            if (!response.success || !response.data || !response.data.photos) {
1389                traceVideoHeal('refresh-fetch-invalid-response', {
1390                    reason: reason,
1391                    albumUrl: shortMediaUrl(albumUrl),
1392                    containerId: $albumContainer.attr('id') || ''
1393                });
1394                return $.Deferred().reject().promise();
1395            }
1396            traceVideoHeal('refresh-fetch-success', {
1397                reason: reason,
1398                albumUrl: shortMediaUrl(albumUrl),
1399                containerId: $albumContainer.attr('id') || '',
1400                mediaCount: response.data.photos.length
1401            });
1402            return response.data.photos;
1403        }).fail(function(jqXHR, textStatus, errorThrown) {
1404            traceVideoHeal('refresh-fetch-fail', {
1405                reason: reason,
1406                albumUrl: shortMediaUrl(albumUrl),
1407                containerId: $albumContainer.attr('id') || '',
1408                status: jqXHR && typeof jqXHR.status === 'number' ? jqXHR.status : null,
1409                textStatus: textStatus || '',
1410                error: errorThrown || ''
1411            });
1412        }).always(function() {
1413            if (_refreshPromises[albumUrl] === refreshRequest) {
1414                delete _refreshPromises[albumUrl];
1415            }
1416        });
1417        _refreshPromises[albumUrl] = refreshRequest;
1418
1419        return refreshRequest.then(function(freshPhotos) {
1420            if (!shouldApply || shouldApply()) {
1421                applyFreshMediaToContainer($albumContainer, freshPhotos, {
1422                    reason: reason,
1423                    triggerVideoEl: triggerVideoEl,
1424                    forceTriggerReload: forceTriggerReload
1425                });
1426            }
1427            return freshPhotos;
1428        });
1429    }
1430
1431    function getPhotoGlobalIndex(photo, fallbackIndex) {
1432        if (photo && photo.globalIndex !== undefined && photo.globalIndex !== null) {
1433            var parsed = parseInt(photo.globalIndex, 10);
1434            if (!isNaN(parsed) && parsed >= 0) {
1435                return parsed;
1436            }
1437        }
1438        return (typeof fallbackIndex === 'number' && fallbackIndex >= 0) ? fallbackIndex : 0;
1439    }
1440
1441    function getContainerPhotos($container) {
1442        var photosJson = $container.attr('data-all-photos');
1443        if (!photosJson) {
1444            return [];
1445        }
1446        try {
1447            var photos = JSON.parse(photosJson);
1448            return Array.isArray(photos) ? photos : [];
1449        } catch (e) {
1450            return [];
1451        }
1452    }
1453
1454    function setContainerPhotos($container, photos) {
1455        $container.attr('data-all-photos', JSON.stringify(Array.isArray(photos) ? photos : []));
1456    }
1457
1458    function getContainerTotalCount($container) {
1459        var totalCount = parseInt($container.attr('data-total-count'), 10);
1460        if (!isNaN(totalCount) && totalCount >= 0) {
1461            return totalCount;
1462        }
1463        return getContainerPhotos($container).length;
1464    }
1465
1466    function clampMosaicVisibleCount(rawCount, thumbCount) {
1467        var count = parseInt(rawCount, 10);
1468        if (isNaN(count) || count < 1) {
1469            count = 1;
1470        }
1471
1472        var total = parseInt(thumbCount, 10);
1473        if (isNaN(total) || total < 1) {
1474            return count;
1475        }
1476
1477        return Math.min(count, total);
1478    }
1479
1480    function findPhotoLocalIndex(photos, globalIndex) {
1481        for (var i = 0; i < photos.length; i++) {
1482            if (getPhotoGlobalIndex(photos[i], i) === globalIndex) {
1483                return i;
1484            }
1485        }
1486        return -1;
1487    }
1488
1489    function getContainerPhotoByGlobalIndex($container, globalIndex) {
1490        var photos = getContainerPhotos($container);
1491        if (!photos.length || globalIndex < 0) {
1492            return {};
1493        }
1494
1495        if (photos[globalIndex] && getPhotoGlobalIndex(photos[globalIndex], globalIndex) === globalIndex) {
1496            return photos[globalIndex];
1497        }
1498
1499        var localIndex = findPhotoLocalIndex(photos, globalIndex);
1500        return localIndex >= 0 ? (photos[localIndex] || {}) : {};
1501    }
1502
1503    function replaceContainerPhotoByGlobalIndex($container, globalIndex, updatedPhoto) {
1504        var photos = getContainerPhotos($container);
1505        var localIndex = findPhotoLocalIndex(photos, globalIndex);
1506        if (localIndex < 0) {
1507            return false;
1508        }
1509        photos[localIndex] = updatedPhoto;
1510        setContainerPhotos($container, photos);
1511        return true;
1512    }
1513
1514    function getProgressiveSliderSettings($container) {
1515        return {
1516            enabled: $container.attr('data-progressive-loading') === 'true',
1517            initialChunkSize: Math.max(1, parseInt($container.attr('data-progressive-initial-chunk-size'), 10) || 24),
1518            chunkSize: Math.max(1, parseInt($container.attr('data-progressive-chunk-size'), 10) || 24),
1519            limit: Math.max(1, parseInt($container.attr('data-progressive-limit'), 10) || getContainerTotalCount($container) || 1),
1520            showVideos: $container.attr('data-progressive-show-videos') === 'true',
1521            sourceWidth: Math.max(1, parseInt($container.attr('data-progressive-source-width'), 10) || 800),
1522            sourceHeight: Math.max(1, parseInt($container.attr('data-progressive-source-height'), 10) || 600),
1523            fullscreenSourceWidth: Math.max(1, parseInt($container.attr('data-progressive-fullscreen-source-width'), 10) || 1920),
1524            fullscreenSourceHeight: Math.max(1, parseInt($container.attr('data-progressive-fullscreen-source-height'), 10) || 1440)
1525        };
1526    }
1527
1528    function fetchAlbumChunk($container, offset, count) {
1529        if (typeof jzsaAjax === 'undefined' || !jzsaAjax.ajaxUrl || !jzsaAjax.chunkNonce) {
1530            return $.Deferred().reject('missing-ajax-config').promise();
1531        }
1532
1533        var settings = getProgressiveSliderSettings($container);
1534        var albumUrl = $container.attr('data-album-url') || '';
1535        if (!albumUrl) {
1536            return $.Deferred().reject('missing-album-url').promise();
1537        }
1538
1539        return $.ajax({
1540            url: jzsaAjax.ajaxUrl,
1541            type: 'POST',
1542            data: {
1543                action: 'jzsa_fetch_album_chunk',
1544                nonce: jzsaAjax.chunkNonce,
1545                album_url: albumUrl,
1546                offset: Math.max(0, offset),
1547                count: Math.max(1, count),
1548                limit: settings.limit,
1549                show_videos: settings.showVideos ? 'true' : 'false',
1550                source_width: settings.sourceWidth,
1551                source_height: settings.sourceHeight,
1552                fullscreen_source_width: settings.fullscreenSourceWidth,
1553                fullscreen_source_height: settings.fullscreenSourceHeight
1554            }
1555        }).then(function(response) {
1556            if (!response || !response.success || !response.data || !Array.isArray(response.data.photos)) {
1557                return $.Deferred().reject('invalid-chunk-response').promise();
1558            }
1559            return response.data;
1560        });
1561    }
1562
1563    function computeInitialGlobalPhotoIndex($container, totalCount) {
1564        var startAtRaw = (($container.attr('data-start-at') || '1') + '').toLowerCase();
1565        if (totalCount <= 0) {
1566            return 0;
1567        }
1568        if (startAtRaw === 'random') {
1569            return Math.floor(Math.random() * totalCount);
1570        }
1571        var requested = parseInt(startAtRaw, 10);
1572        if (isNaN(requested) || requested < 1 || requested > totalCount) {
1573            requested = 1;
1574        }
1575        return requested - 1;
1576    }
1577
1578    function bootstrapProgressiveSlider($container, mode) {
1579        var existingPromise = $container.data('jzsaProgressiveBootstrap');
1580        if (existingPromise) {
1581            return existingPromise;
1582        }
1583
1584        var totalCount = getContainerTotalCount($container);
1585        var settings = getProgressiveSliderSettings($container);
1586        var initialGlobalIndex = computeInitialGlobalPhotoIndex($container, totalCount);
1587        var initialOffset = Math.max(
1588            0,
1589            Math.min(
1590                initialGlobalIndex - Math.floor(settings.initialChunkSize / 2),
1591                Math.max(0, totalCount - settings.initialChunkSize)
1592            )
1593        );
1594
1595        $container.data('jzsaProgressiveInitialGlobalIndex', initialGlobalIndex);
1596        $container
1597            .removeClass('jzsa-loaded')
1598            .addClass('jzsa-loader-pending');
1599        activateLoaderState($container, 'Loading content...', 'This is taking longer than usual...');
1600        var progressiveLoaderShownAt = Date.now();
1601
1602        var bootstrapPromise = fetchAlbumChunk($container, initialOffset, settings.initialChunkSize)
1603            .done(function(data) {
1604                setContainerPhotos($container, data.photos);
1605                if (data.total_count !== undefined && data.total_count !== null) {
1606                    $container.attr('data-total-count', data.total_count);
1607                }
1608                var minVisibleRemaining = progressiveLoaderShownAt > 0
1609                    ? Math.max(0, LOADER_MIN_VISIBLE_MS - (Date.now() - progressiveLoaderShownAt))
1610                    : 0;
1611                window.setTimeout(function() {
1612                    $container.removeData('jzsaProgressiveBootstrap');
1613                    $container.removeAttr('data-jzsa-initialized');
1614                    initializeSwiper($container[0], mode);
1615                }, minVisibleRemaining);
1616            })
1617            .fail(function() {
1618                resetLoaderState($container);
1619                $container
1620                    .removeClass('jzsa-loader-pending')
1621                    .addClass('jzsa-loaded');
1622                $container.removeData('jzsaProgressiveBootstrap');
1623            });
1624
1625        $container.data('jzsaProgressiveBootstrap', bootstrapPromise);
1626        return bootstrapPromise;
1627    }
1628
1629    function setupProgressiveSliderChunkLoading(swiper, $container, progressiveParam
1629s) {
1630        if (!progressiveParams || !progressiveParams.enabled || !swiper) {
1631            return;
1632        }
1633
1634        var chunkSize = Math.max(1, progressiveParams.chunkSize || 24);
1635        var totalCount = Math.max(0, progressiveParams.totalCount || getContainerTotalCount($container));
1636        var zoneFormats = progressiveParams.zoneFormats || null;
1637        var prefetchMargin = Math.max(4, Math.floor(chunkSize / 3));
1638        var loadingBefore = false;
1639        var loadingAfter = false;
1640
1641        function getLoadedPhotos() {
1642            return getContainerPhotos($container);
1643        }
1644
1645        function syncLoadedPhotos(photos) {
1646            setContainerPhotos($container, photos);
1647        }
1648
1649        function maybeEnableRewind(photos) {
1650            if (!photos.length) {
1651                return;
1652            }
1653            var firstIndex = getPhotoGlobalIndex(photos[0], 0);
1654            var lastIndex = getPhotoGlobalIndex(photos[photos.length - 1], photos.length - 1);
1655            if (firstIndex === 0 && lastIndex >= totalCount - 1 && !swiper.params.rewind) {
1656                swiper.params.rewind = true;
1657                swiper.update();
1658            }
1659        }
1660
1661        function mergeChunk(direction, chunkPhotos) {
1662            if (!chunkPhotos || !chunkPhotos.length) {
1663                maybeEnableRewind(getLoadedPhotos());
1664                return;
1665            }
1666
1667            var loadedPhotos = getLoadedPhotos();
1668            var existing = {};
1669            for (var i = 0; i < loadedPhotos.length; i++) {
1670                existing[getPhotoGlobalIndex(loadedPhotos[i], i)] = true;
1671            }
1672
1673            var mergedPhotos = [];
1674            for (var j = 0; j < chunkPhotos.length; j++) {
1675                var globalIndex = getPhotoGlobalIndex(chunkPhotos[j], j);
1676                if (!existing[globalIndex]) {
1677                    mergedPhotos.push(chunkPhotos[j]);
1678                    existing[globalIndex] = true;
1679                }
1680            }
1681
1682            if (!mergedPhotos.length) {
1683                maybeEnableRewind(loadedPhotos);
1684                return;
1685            }
1686
1687            var slideMarkupList = mergedPhotos.map(function(photo) {
1688                return buildSlidesHtml([photo], {
1689                    mode: 'slider',
1690                    lazyHints: true,
1691                    eagerIndex: -1
1692                });
1693            });
1694
1695            if (direction === 'before') {
1696                syncLoadedPhotos(mergedPhotos.concat(loadedPhotos));
1697                // Swiper's prependSlide prepends each array element individually via
1698                // DOM prepend(), which reverses the order. Reversing the list first
1699                // cancels the reversal so the lowest globalIndex lands at position 0.
1700                // Swiper also calls slideTo(activeIndex + count) and update() internally,
1701                // so no explicit compensation is needed here.
1702                swiper.prependSlide(slideMarkupList.slice().reverse());
1703            } else {
1704                syncLoadedPhotos(loadedPhotos.concat(mergedPhotos));
1705                swiper.appendSlide(slideMarkupList);
1706                swiper.update();
1707            }
1708
1709            // Swiper's appendSlide/prependSlide both call update() internally,
1710            // which calls slideTo(activeIndex, 0) and emits 'beforeTransitionStart'.
1711            // The autoplay module handles this by pausing and waiting for a CSS
1712            // transitionend that never fires (speed is 0). Resume it immediately.
1713            if (swiper.autoplay && swiper.autoplay.running && swiper.autoplay.paused) {
1714                swiper.autoplay.resume();
1715            }
1716
1717            scheduleDurationPrefetch($container);
1718            if (zoneFormats) {
1719                scheduleExifPrefetch($container, zoneFormats);
1720            }
1721            maybeEnableRewind(getLoadedPhotos());
1722
1723            // If we've reached one end, keep loading the other side until the
1724            // whole album is ready and rewind can turn on. setTimeout waits
1725            // for the current load to finish before starting the next one.
1726            if (!swiper.params.rewind) {
1727                var allLoaded = getLoadedPhotos();
1728                var fIdx = allLoaded.length ? getPhotoGlobalIndex(allLoaded[0], 0) : -1;
1729                var lIdx = allLoaded.length ? getPhotoGlobalIndex(allLoaded[allLoaded.length - 1], allLoaded.length - 1) : -1;
1730                if (fIdx === 0 && lIdx < totalCount - 1) {
1731                    window.setTimeout(ensureChunkAfter, 0);
1732                }
1733                if (lIdx >= totalCount - 1 && fIdx > 0) {
1734                    window.setTimeout(ensureChunkBefore, 0);
1735                }
1736            }
1737        }
1738
1739        function ensureChunkBefore() {
1740            if (loadingBefore) {
1741                return;
1742            }
1743            var loadedPhotos = getLoadedPhotos();
1744            if (!loadedPhotos.length) {
1745                return;
1746            }
1747            var firstIndex = getPhotoGlobalIndex(loadedPhotos[0], 0);
1748            if (firstIndex <= 0) {
1749                maybeEnableRewind(loadedPhotos);
1750                return;
1751            }
1752            loadingBefore = true;
1753            var beforeOffset = Math.max(0, firstIndex - chunkSize);
1754            var beforeCount = firstIndex - beforeOffset;
1755            fetchAlbumChunk($container, beforeOffset, beforeCount)
1756                .done(function(data) {
1757                    mergeChunk('before', data.photos || []);
1758                })
1759                .always(function() {
1760                    loadingBefore = false;
1761                });
1762        }
1763
1764        function ensureChunkAfter() {
1765            if (loadingAfter) {
1766                return;
1767            }
1768            var loadedPhotos = getLoadedPhotos();
1769            if (!loadedPhotos.length) {
1770                return;
1771            }
1772            var lastIndex = getPhotoGlobalIndex(loadedPhotos[loadedPhotos.length - 1], loadedPhotos.length - 1);
1773            if (lastIndex >= totalCount - 1) {
1774                maybeEnableRewind(loadedPhotos);
1775                return;
1776            }
1777            loadingAfter = true;
1778            var afterOffset = lastIndex + 1;
1779            var afterCount = Math.min(chunkSize, totalCount - afterOffset);
1780            fetchAlbumChunk($container, afterOffset, afterCount)
1781                .done(function(data) {
1782                    mergeChunk('after', data.photos || []);
1783                })
1784                .always(function() {
1785                    loadingAfter = false;
1786                });
1787        }
1788
1789        function ensureBuffer() {
1790            var loadedPhotos = getLoadedPhotos();
1791            if (!loadedPhotos.length) {
1792                return;
1793            }
1794
1795            var activeGlobalIndex = getSwiperPhotoIndex(swiper);
1796            var firstIndex = getPhotoGlobalIndex(loadedPhotos[0], 0);
1797            var lastIndex = getPhotoGlobalIndex(loadedPhotos[loadedPhotos.length - 1], loadedPhotos.length - 1);
1798
1799            if (activeGlobalIndex - firstIndex <= prefetchMargin) {
1800                ensureChunkBefore();
1801            }
1802            if (lastIndex - activeGlobalIndex <= prefetchMargin) {
1803                ensureChunkAfter();
1804            }
1805        }
1806
1807        maybeEnableRewind(getLoadedPhotos());
1808        // Eagerly pre-fetch in both directions regardless of the current position.
1809        // Each function already knows when there is nothing to fetch (firstIndex === 0,
1810        // lastIndex >= totalCount - 1) and returns early in those cases. This covers
1811        // the scenario where the initial chunk is centred in the album and the user
1812        // navigates backwards before the margin-based ensureBuffer would have fired.
1813        ensureChunkBefore();
1814        ensureChunkAfter();
1815        swiper.on('slideChange', ensureBuffer);
1816        swiper.on('slideChangeTransitionEnd', ensureBuffer);
1817        swiper.on('reachBeginning', ensureBuffer);
1818        swiper.on('reachEnd', ensureBuffer);
1819    }
1820
1821    /**
1822     * Initialise Plyr on all uninitialised .jzsa-video-player elements
1823     * inside the given container.
1824     *
1825     * @param {jQuery} $container Parent element to search within.
1826     */
1827    function initPlyrInContainer($container) {
1828        if (typeof Plyr === 'undefined') {
1829            // console.warn('⚠️ Plyr not loaded');
1830            return;
1831        }
1832        var $album = $container.closest('.jzsa-album, .jzsa-gallery');
1833        var autohide = $album.length ? $album.attr('data-video-controls-autohide') === 'true' : false;
1834        var videos = $container.find('video.jzsa-video-player');
1835        // console.log('🎬 initPlyrInContainer: found', videos.length, 'videos');
1836        videos.each(function() {
1837            if (this._jzsaPlyr) {
1838                return;
1839            }
1840            var wrapper = $(this).closest('.jzsa-video-wrapper')[0];
1841            this._jzsaPlyr = new Plyr(this, {
1842                iconUrl: (typeof jzsaAjax !== 'undefined' && jzsaAjax.plyrSvgUrl) || '',
1843                controls: ['play-large', 'play', 'restart', 'progress', 'current-time', 'duration', 'mute', 'volume'],
1844                clickToPlay: false,
1845                hideControls: autohide,
1846                resetOnEnd: true,
1847                invertTime: false,
1848                disableContextMenu: false,
1849                fullscreen: { enabled: false }
1850            });
1851            // Hide bottom control bar until playback starts
1852            var plyrContainer = $(this).closest('.jzsa-video-wrapper').find('.plyr__controls');
1853            if (plyrContainer.length) {
1854                plyrContainer.hide();
1855            }
1856            var $wrapper = $(wrapper);
1857            var $albumContainer = $wrapper.closest('.jzsa-album, .jzsa-gallery-album');
1858            var $playLarge = $wrapper.find('.plyr__control--overlaid');
1859
1860            // When the video is playing, intercept clicks on the overlaid
1861            // button to pause instead of plyr's default (re-play).
1862            // Capture phase ensures this fires before plyr's own handler.
1863            $playLarge[0].addEventListener('click', function(e) {
1864                if (plyrRef && plyrRef.playing) {
1865                    e.stopImmediatePropagation();
1866                    e.preventDefault();
1867                    plyrRef.pause();
1868                }
1869            }, true);
1870
1871            // Loading state + auto-heal finite-state machine.
1872            var LOADING_TIMEOUT_MS = 30000;
1873            var STALL_WATCHDOG_MS = 5000;
1874            var MAX_AUTO_HEAL_ATTEMPTS = 2;
1875            var HEAL_RETRY_DELAY_MS = 50;
1876
1877            var VIDEO_STATE_IDLE = 'idle';
1878            var VIDEO_STATE_STARTING = 'starting';
1879            var VIDEO_STATE_HEALING = 'healing';
1880            var VIDEO_STATE_PLAYING = 'playing';
1881            var VIDEO_STATE_FAILED = 'failed';
1882
1883            var loadingTimeout = null;
1884            var stallWatchdogTimer = null;
1885            var playbackState = VIDEO_STATE_IDLE;
1886            // Play button starts in default triangle state - allow duration badge to show.
1887            $wrapper.addClass('jzsa-show-duration');
1888            var autoHealAttempts = 0;
1889            var healRunToken = 0;
1890            var plyrRef = this._jzsaPlyr;
1891            var videoEl = this;
1892
1893            function isStartingState() {
1894                return playbackState === VIDEO_STATE_STARTING || playbackState === VIDEO_STATE_HEALING;
1895            }
1896
1897            function isPlaybackActive() {
1898                return isStartingState() || playbackState === VIDEO_STATE_PLAYING;
1899            }
1900
1901            function setPlaybackState(nextState) {
1902                playbackState = nextState;
1903                // Duration label must only be visible when play button shows its default triangle.
1904                if (nextState === VIDEO_STATE_IDLE && !$playLarge.hasClass('jzsa-plyr-error')) {
1905                    $wrapper.addClass('jzsa-show-duration');
1906                } else {
1907                    $wrapper.removeClass('jzsa-show-duration');
1908                }
1909            }
1910
1911            function trace(eventName, extra) {
1912                traceVideoHeal(eventName, $.extend({
1913                    containerId: $albumContainer.attr('id') || '',
1914                    mediaIndex: videoEl.getAttribute('data-jzsa-media-index') || null,
1915                    state: playbackState,
1916                    waitingForPlay: isStartingState(),
1917                    healingInProgress: playbackState === VIDEO_STATE_HEALING,
1918                    playing: playbackState === VIDEO_STATE_PLAYING || !!(plyrRef && plyrRef.playing),
1919                    attempts: autoHealAttempts,
1920                    currentTime: (typeof videoEl.currentTime === 'number' ? videoEl.currentTime : null),
1921                    src: shortMediaUrl(videoEl.currentSrc || videoEl.getAttribute('src') || '')
1922                }, extra || {}));
1923            }
1924
1925            function clearLoadingTimeout() {
1926                if (loadingTimeout) {
1927                    clearTimeout(loadingTimeout);
1928                    loadingTimeout = null;
1929                }
1930            }
1931
1932            function clearStallWatchdog() {
1933                if (stallWatchdogTimer) {
1934                    clearTimeout(stallWatchdogTimer);
1935                    stallWatchdogTimer = null;
1936                }
1937            }
1938
1939            function clearRecoveryTimers() {
1940                clearLoadingTimeout();
1941                clearStallWatchdog();
1942            }
1943
1944            function showLoadingUi() {
1945                $playLarge.addClass('jzsa-plyr-loading');
1946                $wrapper.addClass('jzsa-video-loading');
1947            }
1948
1949            function clearLoadingUi() {
1950                $playLarge.removeClass('jzsa-plyr-loading');
1951                $wrapper.removeClass('jzsa-video-loading');
1952            }
1953
1954            function armLoadingTimeout(source) {
1955                clearLoadingTimeout();
1956                if (!isStartingState()) {
1957                    return;
1958                }
1959                loadingTimeout = setTimeout(function() {
1960                    if (!isStartingState()) {
1961                        return;
1962                    }
1963                    trace('loading-timeout', {
1964                        source: source,
1965                        timeoutMs: LOADING_TIMEOUT_MS,
1966                        readyState: videoEl.readyState,
1967                        networkState: videoEl.networkState
1968                    });
1969                    if (autoHealAttempts < MAX_AUTO_HEAL_ATTEMPTS) {
1970                        runRecovery('loading-timeout', 'soft');
1971                        return;
1972                    }
1973                    failPlayback('loading-timeout');
1974                }, LOADING_TIMEOUT_MS);
1975            }
1976
1977            function armStallWatchdog(source) {
1978                clearStallWatchdog();
1979                if (!isPlaybackActive()) {
1980                    return;
1981                }
1982                stallWatchdogTimer = setTimeout(function() {
1983                    if (!isPlaybackActive() || playbackState === VIDEO_STATE_PLAYING) {
1984                        return;
1985                    }
1986                    trace('stall-watchdog-fired', {
1987                        source: source,
1988                        timeoutMs: STALL_WATCHDOG_MS,
1989                        readyState: videoEl.readyState,
1990                        networkState: videoEl.networkState
1991                    });
1992                    runRecovery('stall-watchdog', 'soft');
1993                }, STALL_WATCHDOG_MS);
1994            }
1995
1996            function failPlayback(reason, extra) {
1997                healRunToken++;
1998                setPlaybackState(VIDEO_STATE_FAILED);
1999                clearRecoveryTimers();
2000                clearLoadingUi();
2001                $playLarge.addClass('jzsa-plyr-error');
2002                trace('playback-failed', $.extend({ reason: reason }, extra || {}));
2003                if (!plyrRef.playing) {
2004                    videoEl._jzsaSuppressNextError = true;
2005                    try { plyrRef.stop(); } catch (e) { /* ignore */ }
2006                }
2007            }
2008
2009            function runRecovery(reason, mode) {
2010                var isHard = mode === 'hard';
2011
2012                if (!isPlaybackActive()) {
2013                    trace('heal-skip-not-active', { reason: reason, mode: mode });
2014                    return;
2015                }
2016                if (playbackState === VIDEO_STATE_HEALING && !isHard) {
2017                    var canOverrideInflight =
2018                        reason === 'loading-timeout' ||
2019                        reason === 'stall-watchdog';
2020                    if (!canOverrideInflight) {
2021                        trace('heal-skip-inflight', { reason: reason, mode: mode });
2022                        return;
2023                    }
2024                    // A previous heal run appears stuck (e.g. unresolved play promise).
2025                    // Invalidate it and start a fresh recovery attempt.
2026                    healRunToken++;
2027                    setPlaybackState(VIDEO_STATE_STARTING);
2028                    trace('heal-override-stuck-inflight', { reason: reason, mode: mode });
2029                }
2030                if (isHard) {
2031                    autoHealAttempts = 0;
2032                }
2033                if (autoHealAttempts >= MAX_AUTO_HEAL_ATTEMPTS) {
2034                    failPlayback('max-attempts-reached', { trigger: reason });
2035                    return;
2036                }
2037
2038                var runToken = ++healRunToken;
2039                autoHealAttempts++;
2040                setPlaybackState(VIDEO_STATE_HEALING);
2041                showLoadingUi();
2042                trace('heal-start', { reason: reason, mode: mode, attempt: autoHealAttempts });
2043                armLoadingTimeout('heal-start');
2044                armStallWatchdog('heal-start');
2045
2046                refreshAlbumUrls($albumContainer, {
2047                    reason: 'auto-heal:' + reason,
2048                    triggerVideoEl: videoEl,
2049                    forceNetwork: isHard,
2050                    forceTriggerReload: isHard,
2051                    shouldApply: function() {
2052                        return runToken === healRunToken;
2053                    }
2054                }).then(function() {
2055                    if (runToken !== healRunToken) {
2056                        return;
2057                    }
2058                    trace('heal-refresh-applied', { reason: reason, attempt: autoHealAttempts });
2059                    var playPromise;
2060                    try {
2061                        playPromise = plyrRef.play();
2062                    } catch (err) {
2063                        if (runToken !== healRunToken) {
2064                            return;
2065                        }
2066                        setPlaybackState(VIDEO_STATE_STARTING);
2067                        trace('heal-play-rejected', {
2068                            reason: reason,
2069                            attempt: autoHealAttempts,
2070                            error: err && err.message ? err.message : String(err || '')
2071                        });
2072                        if (autoHealAttempts >= MAX_AUTO_HEAL_ATTEMPTS) {
2073                            failPlayback('play-rejected-after-heal', { trigger: reason });
2074                            return;
2075                        }
2076                        setTimeout(function() {
2077                            if (runToken !== healRunToken) {
2078                                return;
2079                            }
2080                            runRecovery('play-rejected', 'soft');
2081                        }, HEAL_RETRY_DELAY_MS);
2082                        return;
2083                    }
2084
2085                    armStallWatchdog('post-heal-play');
2086
2087                    if (playPromise && typeof playPromise.then === 'function') {
2088                        playPromise.then(function() {
2089                            if (runToken !== healRunToken) {
2090                                return;
2091                            }
2092                            if (playbackState === VIDEO_STATE_HEALING) {
2093                                setPlaybackState(VIDEO_STATE_STARTING);
2094                            }
2095                        }).catch(function(err) {
2096                            if (runToken !== healRunToken) {
2097                                return;
2098                            }
2099                            setPlaybackState(VIDEO_STATE_STARTING);
2100                            trace('heal-play-rejected', {
2101                                reason: reason,
2102                                attempt: autoHealAttempts,
2103                                error: err && err.message ? err.message : String(err || '')
2104                            });
2105                            if (autoHealAttempts >= MAX_AUTO_HEAL_ATTEMPTS) {
2106                                failPlayback('play-rejected-after-heal', { trigger: reason });
2107                                return;
2108                            }
2109                            setTimeout(function() {
2110                                if (runToken !== healRunToken) {
2111                                    return;
2112                                }
2113                                runRecovery('play-rejected', 'soft');
2114                            }, HEAL_RETRY_DELAY_MS);
2115                        });
2116                        return;
2117                    }
2118
2119                    // Browsers without play() promise support.
2120                    setTimeout(function() {
2121                        if (runToken !== healRunToken) {
2122                            return;
2123                        }
2124                        if (playbackState === VIDEO_STATE_HEALING) {
2125                            setPlaybackState(VIDEO_STATE_STARTING);
2126                        }
2127                    }, 800);
2128                }).fail(function() {
2129                    if (runToken !== healRunToken) {
2130                        return;
2131                    }
2132                    setPlaybackState(VIDEO_STATE_STARTING);
2133                    trace('heal-refresh-failed', { reason: reason, attempt: autoHealAttempts });
2134                    if (autoHealAttempts >= MAX_AUTO_HEAL_ATTEMPTS) {
2135                        failPlayback('refresh-failed', { trigger: reason });
2136                        return;
2137                    }
2138                    setTimeout(function() {
2139                        if (runToken !== healRunToken) {
2140                            return;
2141                        }
2142                        runRecovery('refresh-failed-retry', 'soft');
2143                    }, 250);
2144                });
2145            }
2146
2147            // Expose a cancel function so other videos can abort our buffering
2148            videoEl._jzsaCancelLoading = function() {
2149                if (!isStartingState()) { return; }
2150                trace('cancel-loading');
2151                healRunToken++;
2152                setPlaybackState(VIDEO_STATE_IDLE);
2153                autoHealAttempts = 0;
2154                clearRecoveryTimers();
2155                clearLoadingUi();
2156                videoEl._jzsaSuppressNextError = true;
2157                plyrRef.stop();
2158            };
2159
2160            function pauseAllPageVideos() {
2161                document.querySelectorAll('video.jzsa-video-player').forEach(function(v) {
2162                    if (v === videoEl) { return; }
2163                    // Cancel buffering if still loading
2164                    if (v._jzsaCancelLoading) { v._jzsaCancelLoading(); }
2165                    // Stop (reset to start) if already playing
2166                    if (v._jzsaPlyr && v._jzsaPlyr.playing) {
2167                        v._jzsaSuppressNextError = true;
2168                        v._jzsaPlyr.stop();
2169                    }
2170                });
2171            }
2172
2173            function showPlaying() {
2174                healRunToken++;
2175                setPlaybackState(VIDEO_STATE_PLAYING);
2176                autoHealAttempts = 0;
2177                clearRecoveryTimers();
2178                clearLoadingUi();
2179                $playLarge.removeClass('jzsa-plyr-error');
2180                plyrContainer.show();
2181                $albumContainer.addClass('jzsa-video-playing');
2182                trace('playback-confirmed');
2183            }
2184
2185            function requestPlaybackStart(source) {
2186                var playPromise;
2187                try {
2188                    playPromise = plyrRef.play();
2189                } catch (err) {
2190                    if (!isStartingState()) {
2191                        return;
2192                    }
2193                    trace('play-start-threw', {
2194                        source: source,
2195                        error: err && err.message ? err.message : String(err || '')
2196                    });
2197                    runRecovery('play-start-threw', 'soft');
2198                    return;
2199                }
2200                if (playPromise && typeof playPromise.catch === 'function') {
2201                    playPromise.catch(function(err) {
2202                        if (!isStartingState()) {
2203                            return;
2204                        }
2205                        trace('play-start-rejected', {
2206                            source: source,
2207                            error: err && err.message ? err.message : String(err || '')
2208                        });
2209                        runRecovery('play-start-rejected', 'soft');
2210                    });
2211                }
2212            }
2213
2214            $playLarge.on('click', function() {
2215                if (isStartingState()) {
2216                    trace('play-click-hard-retry');
2217                    runRecovery('user-click-hard-retry', 'hard');
2218                    armLoadingTimeout('user-click-hard-retry');
2219                    return;
2220                }
2221                // Clear any previous error state
2222                $playLarge.removeClass('jzsa-plyr-error');
2223                // Stop all other videos immediately on click
2224                pauseAllPageVideos();
2225                setPlaybackState(VIDEO_STATE_STARTING);
2226                autoHealAttempts = 0;
2227                showLoadingUi();
2228                trace('play-click');
2229
2230                // 30s timeout - show error state if playback never starts
2231                armLoadingTimeout('initial-click');
2232
2233                armStallWatchdog('initial-click');
2234                requestPlaybackStart('play-click');
2235            });
2236
2237            // timeupdate fires when frames are actively rendering.
2238            // Wait until currentTime > 0.1s to be sure a real frame has
2239            // painted over the poster (and its baked-in triangle).
2240            this._jzsaPlyr.on('playing', function() {
2241                if (isStartingState() || !$albumContainer.hasClass('jzsa-video-playing')) {
2242                    showPlaying();
2243                }
2244            });
2245            this._jzsaPlyr.on('timeupdate', function() {
2246                if (!isStartingState()) { return; }
2247                if (plyrRef.currentTime > 0.1) {
2248                    showPlaying();
2249                }
2250            });
2251
2252            videoEl.addEventListener('waiting', function() {
2253                if (!isPlaybackActive() && !plyrRef.playing) {
2254                    return;
2255                }
2256                trace('native-waiting', {
2257                    readyState: videoEl.readyState,
2258                    networkState: videoEl.networkState
2259                });
2260                armStallWatchdog('native-waiting');
2261            });
2262
2263            videoEl.addEventListener('stalled', function() {
2264                if (!isPlaybackActive() && !plyrRef.playing) {
2265                    return;
2266                }
2267                trace('native-stalled', {
2268                    readyState: videoEl.readyState,
2269                    networkState: videoEl.networkState
2270                });
2271                armStallWatchdog('native-stalled');
2272            });
2273
2274            this._jzsaPlyr.on('pause', function() {
2275                healRunToken++;
2276                setPlaybackState(VIDEO_STATE_IDLE);
2277                autoHealAttempts = 0;
2278                clearRecoveryTimers();
2279                clearLoadingUi();
2280                plyrContainer.hide();
2281                $albumContainer.removeClass('jzsa-video-playing');
2282                $albumContainer.trigger('jzsa:video-stopped');
2283                trace('plyr-pause');
2284            });
2285            this._jzsaPlyr.on('ended', function() {
2286                healRunToken++;
2287                setPlaybackState(VIDEO_STATE_IDLE);
2288                autoHealAttempts = 0;
2289                clearRecoveryTimers();
2290                clearLoadingUi();
2291                plyrContainer.hide();
2292                $albumContainer.removeClass('jzsa-video-playing');
2293                $albumContainer.trigger('jzsa:video-stopped');
2294                trace('plyr-ended');
2295            });
2296
2297            // If the video URL has expired (HTTP 400), re-fetch fresh URLs
2298            // from Google Photos and retry.
2299            this._jzsaPlyr.on('error', function() {
2300                if (videoEl._jzsaSuppressNextError) {
2301                    videoEl._jzsaSuppressNextError = false;
2302                    trace('plyr-error-suppressed');
2303                    return;
2304                }
2305
2306                trace('plyr-error', {
2307                    readyState: videoEl.readyState,
2308                    networkState: videoEl.networkState
2309                });
2310
2311                // Some failures drop playing=false before the error callback
2312                // fires. Recover if playback had already progressed.
2313                var hadPlaybackProgress =
2314                    typeof videoEl.currentTime === 'number' &&
2315                    videoEl.currentTime > 0.1 &&
2316                    !videoEl.ended;
2317                var shouldRecover = isStartingState() || playbackState === VIDEO_STATE_PLAYING || hadPlaybackProgress;
2318                if (!shouldRecover) {
2319                    trace('plyr-error-ignored-inactive');
2320                    return;
2321                }
2322                if (playbackState === VIDEO_STATE_IDLE && hadPlaybackProgress) {
2323                    setPlaybackState(VIDEO_STATE_STARTING);
2324                }
2325                runRecovery('plyr-error', 'soft');
2326            });
2327        });
2328    }
2329
2330    /**
2331     * Destroy any Plyr instances inside the given container.
2332     *
2333     * @param {jQuery} $container Parent element to search within.
2334     */
2335    function destroyPlyrInContainer($container) {
2336        $container.find('video.jzsa-video-player').each(function() {
2337            if (this._jzsaPlyr) {
2338                try { this._jzsaPlyr.destroy(); } catch (e) { /* ignore */ }
2339                this._jzsaPlyr = null;
2340            }
2341        });
2342    }
2343
2344    // ========================================================================
2345    // Video duration prefetch
2346    // ========================================================================
2347
2348    /**
2349     * Lightweight metadata-only prefetch: probes each video to discover its
2350     * duration (a few KB per video) and displays a duration badge on the
2351     * video wrapper. No full video data is downloaded. Durations are cached
2352     * so they persist across DOM rebuilds (e.g. fullscreen toggle).
2353     */
2354    var durationCache = {};      // src -> formatted duration text
2355    var durationQueue = [];      // pending src values
2356    var durationQueued = {};     // src -> true when queued
2357    var durationInFlight = {};   // src -> true when probe is running
2358    var durationPrefetchActive = 0;
2359    var durationPrefetchScheduled = false;
2360    var DURATION_PREFETCH_WORKERS = 3;
2361
2362    function getVideoSrc(videoEl) {
2363        return videoEl.currentSrc || videoEl.src || videoEl.getAttribute('src') || '';
2364    }
2365
2366    function formatDurationText(seconds) {
2367        var mins = Math.floor(seconds / 60);
2368        var secs = Math.floor(seconds % 60);
2369        return mins + ':' + (secs < 10 ? '0' : '') + secs;
2370    }
2371
2372    function applyDurationLabelToVideo(videoEl, durationText) {
2373        if (!durationText) {
2374            return;
2375        }
2376        var $wrapper = $(videoEl).closest('.jzsa-video-wrapper');
2377        if (!$wrapper.length) {
2378            return;
2379        }
2380        var $badge = $wrapper.children('.jzsa-video-duration');
2381        if ($badge.length) {
2382            $badge.text(durationText);
2383            return;
2384        }
2385        $wrapper.append('<span class="jzsa-video-duration">' + durationText + '</span>');
2386    }
2387
2388    /** Apply cached durations to matching video wrappers in scope. */
2389    function applyDurationLabels($scope) {
2390        var $videos = $scope ? $scope.find('video.jzsa-video-player') : $('video.jzsa-video-player');
2391        $videos.each(function() {
2392            var src = getVideoSrc(this);
2393            if (!src || !durationCache[src]) {
2394                return;
2395            }
2396            applyDurationLabelToVideo(this, durationCache[src]);
2397        });
2398    }
2399
2400    function queueDurationPrefetch(src) {
2401        if (!src || durationCache[src] || durationInFlight[src] || durationQueued[src]) {
2402            return;
2403        }
2404        durationQueued[src] = true;
2405        durationQueue.push(src);
2406    }
2407
2408    function processDurationPrefetchQueue() {
2409        while (durationPrefetchActive < DURATION_PREFETCH_WORKERS && durationQueue.length) {
2410            var src = durationQueue.shift();
2411            delete durationQueued[src];
2412            if (!src || durationCache[src] || durationInFlight[src]) {
2413                continue;
2414            }
2415
2416            durationPrefetchActive++;
2417            durationInFlight[src] = true;
2418
2419            (function(videoSrc) {
2420                var probe = document.createElement('video');
2421                probe.preload = 'metadata';
2422                probe.referrerPolicy = 'no-referrer';
2423                var done = false;
2424
2425                function cleanup() {
2426                    if (done) {
2427                        return;
2428                    }
2429                    done = true;
2430                    probe.onloadedmetadata = null;
2431                    probe.onerror = null;
2432                    probe.src = '';
2433                    probe = null;
2434                    delete durationInFlight[videoSrc];
2435                    durationPrefetchActive = Math.max(0, durationPrefetchActive - 1);
2436                    if (durationCache[videoSrc]) {
2437                        applyDurationLabels();
2438                    }
2439                    processDurationPrefetchQueue();
2440                }
2441
2442                probe.onloadedmetadata = function() {
2443                    if (isFinite(probe.duration) && probe.duration > 0) {
2444                        durationCache[videoSrc] = formatDurationText(probe.duration);
2445                    }
2446                    cleanup();
2447                };
2448                probe.onerror = function() {
2449                    cleanup();
2450                };
2451                probe.src = videoSrc;
2452            })(src);
2453        }
2454    }
2455
2456    function scheduleDurationPrefetch($scope) {
2457        applyDurationLabels($scope);
2458
2459        var $videos = $scope ? $scope.find('video.jzsa-video-player') : $('video.jzsa-video-player');
2460        $videos.each(function() {
2461            queueDurationPrefetch(getVideoSrc(this));
2462        });
2463
2464        if (!durationQueue.length) {
2465            return;
2466        }
2467
2468        if (durationPrefetchScheduled) {
2469            return;
2470        }
2471        durationPrefetchScheduled = true;
2472
2473        var schedule = window.requestIdleCallback || function(cb) { setTimeout(cb, 500); };
2474        schedule(function() {
2475            durationPrefetchScheduled = false;
2476            processDurationPrefetchQueue();
2477        });
2478    }
2479
2480    // Helper: Format a timestamp (ms since epoch) as a short locale date string.
2481    function formatPhotoDate(timestampMs) {
2482        if (!timestampMs) {
2483            return '';
2484        }
2485        try {
2486            var d = new Date(parseInt(timestampMs, 10));
2487            if (isNaN(d.getTime())) {
2488                return '';
2489            }
2490            return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
2491        } catch (e) {
2492            return '';
2493        }
2494    }
2495
2496    // Helper: Escape HTML for safe attribute/content insertion.
2497    function escapeHtml(text) {
2498        if (!text) {
2499            return '';
2500        }
2501        var map = {
2502            '&': '&amp;',
2503            '<': '&lt;',
2504            '>': '&gt;',
2505            '"': '&quot;',
2506            "'": '&#039;'
2507        };
2508        return text.replace(/[&<>"']/g, function(m) { return map[m]; });
2509    }
2510
2511    // ========================================================================
2512    // Info box system - resolve placeholder format strings per photo
2513    // ========================================================================
2514
2515    // info-top/info-top-secondary stack top-to-bottom; info-bottom is the Swiper pagination pill.
2516    var INFO_BOX_NAMES = [
2517        'info-top',
2518        'info-top-secondary',
2519        'info-bottom'
2520    ];
2521    var SAFE_INFO_TOP_ORDER = [
2522        'info-top',
2523        'info-top-secondary'
2524    ];
2525    // info-bottom is the Swiper pagination pill - handled separately outside the stack.
2526    var SAFE_INFO_BOTTOM_ORDER = [];
2527    var infoMeasureCanvas = null;
2528    var DEFAULT_INFO_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
2529
2530    /**
2531     * Resolve a format string by replacing placeholders like {date} with photo data.
2532     * Placeholders that resolve to empty are removed along with adjacent separators.
2533     *
2534     * @param {string}
2534 format  Format string (e.g. "{name} · {date}").
2535     * @param {Object} photo   Photo data object.
2536     * @param {Object} context Optional extra placeholder context.
2537     * @return {string} Resolved string, or empty if all placeholders resolved to nothing.
2538     */
2539    function resolveInfoPlaceholders(format, photo, context) {
2540        if (!format || !photo) {
2541            return '';
2542        }
2543        var placeholderContext = context || {};
2544
2545        var name = photo.filename
2546            ? photo.filename.replace(/\.[^.]+$/, '')
2547            : '';
2548
2549        var dims = '';
2550        if (photo.width && photo.height) {
2551            dims = photo.width + '\u00d7' + photo.height;
2552        }
2553
2554        var mp = '';
2555        if (photo.width && photo.height) {
2556            var pixels = parseInt(photo.width, 10) * parseInt(photo.height, 10);
2557            if (pixels > 0) {
2558                mp = (pixels / 1000000).toFixed(1) + ' MP';
2559            }
2560        }
2561
2562        var fs = '';
2563        if (photo.filesize) {
2564            var bytes = parseInt(photo.filesize, 10);
2565            if (bytes >= 1048576) {
2566                fs = (bytes / 1048576).toFixed(1) + ' MB';
2567            } else if (bytes >= 1024) {
2568                fs = Math.round(bytes / 1024) + ' KB';
2569            } else if (bytes > 0) {
2570                fs = bytes + ' B';
2571            }
2572        }
2573
2574        var derivedCamera = buildCameraDisplayName(photo.camera_make, photo.camera_model) || photo.camera || '';
2575
2576        var placeholders = {
2577            '{item}': placeholderContext.item || '',
2578            '{items}': placeholderContext.items || '',
2579            '{page}': placeholderContext.page || '',
2580            '{pages}': placeholderContext.pages || '',
2581            '{album-title}': placeholderContext.albumTitle || '',
2582            '{album-name}': placeholderContext.albumTitle || '',
2583            '{name}': name || '',
2584            '{filename}': photo.filename || '',
2585            '{description}': photo.description || '',
2586            '{date}': photo.timestamp ? formatPhotoDate(photo.timestamp) : '',
2587            '{author}': '',
2588            '{camera}': derivedCamera,
2589            '{camera-make}': photo.camera_make || '',
2590            '{camera-model}': photo.camera_model || '',
2591            '{aperture}': photo.aperture || '',
2592            '{shutter}': photo.shutter || '',
2593            '{focal}': photo.focal || '',
2594            '{iso}': photo.iso || '',
2595            '{dimensions}': dims,
2596            '{megapixels}': mp,
2597            '{filesize}': fs
2598        };
2599
2600        var result = format;
2601        var resolvedAnyValue = false;
2602        for (var placeholder in placeholders) {
2603            if (placeholders.hasOwnProperty(placeholder)) {
2604                if (placeholders[placeholder] !== '' && result.indexOf(placeholder) !== -1) {
2605                    resolvedAnyValue = true;
2606                }
2607                result = result.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), placeholders[placeholder]);
2608            }
2609        }
2610
2611        // Clean up separators around removed placeholders: collapse runs of
2612        // whitespace + separator + whitespace into nothing when adjacent
2613        // to the start/end or another separator. The class covers the
2614        // middle-dot family (· • ∙ ⋅ ⸱ ・), pipes and dashes.
2615        result = result.replace(/^[\s·•∙⋅⸱・|—–-]+/, '').replace(/[\s·•∙⋅⸱・|—–-]+$/, '');
2616        // Collapse internal double-separators (e.g. "foo ·  · bar" → "foo · bar").
2617        result = result.replace(/([·•∙⋅⸱・|—–-])\s*([·•∙⋅⸱・|—–-])/g, '$1');
2618        result = result.replace(/\s{2,}/g, ' ').trim();
2619
2620        // Hide the box entirely when nothing of value remains: no placeholder
2621        // resolved to a value, and only separators/punctuation are left behind.
2622        // This avoids orphaned separators (e.g. "· · ·") when, say, EXIF data
2623        // is absent for every placeholder in the format string.
2624        if (!resolvedAnyValue && !/[\p{L}\p{N}]/u.test(result)) {
2625            return '';
2626        }
2627
2628        return result;
2629    }
2630
2631    function normalizeCameraMakeForDisplay(make) {
2632        make = (make || '').trim();
2633        if (!make) {
2634            return '';
2635        }
2636
2637        var parts = make.split(/(\s+)/);
2638        for (var i = 0; i < parts.length; i++) {
2639            var part = parts[i];
2640            if (!part || !part.trim()) {
2641                continue;
2642            }
2643            if (part.toUpperCase() === part) {
2644                continue;
2645            }
2646            parts[i] = part.replace(/[A-Za-z]+/g, function(word) {
2647                var lower = word.toLowerCase();
2648                return lower.charAt(0).toUpperCase() + lower.slice(1);
2649            });
2650        }
2651        return parts.join('');
2652    }
2653
2654    function buildCameraDisplayName(make, model) {
2655        make = normalizeCameraMakeForDisplay(make);
2656        model = (model || '').trim();
2657
2658        if (!model) {
2659            return make;
2660        }
2661        if (!make) {
2662            return model;
2663        }
2664
2665        var cleanMake = make.replace(/\b(?:corporation|corp\.?|company|co\.?|inc\.?|incorporated|limited|ltd\.?|llc|plc|gmbh|ag|sa|bv|oy|ab|aps)\b/ig, '');
2666        // The suffix regex's trailing \b sits between letter and ".", so the optional
2667        // period (\.?) is left behind on inputs like "Canon Inc." → "Canon .". Str
2667ip
2668        // any leftover edge punctuation before the empty-fallback check so a make
2669        // that collapses to just "." correctly triggers the fallback.
2670        cleanMake = cleanMake.replace(/^[\s.,;:!?-]+|[\s.,;:!?-]+$/g, '');
2671        cleanMake = cleanMake.replace(/\s+/g, ' ').trim();
2672        if (!cleanMake) {
2673            cleanMake = make;
2674        }
2675
2676        var makeCompare = cleanMake.replace(/[^a-z0-9]+/ig, '').toLowerCase();
2677        var modelCompare = model.replace(/[^a-z0-9]+/ig, '').toLowerCase();
2678        if (makeCompare && modelCompare.indexOf(makeCompare) !== -1) {
2679            return model;
2680        }
2681
2682        return (cleanMake + ' ' + model).trim();
2683    }
2684
2685    function isPhotoMetaPending(mediaId) {
2686        return !!(mediaId && (exifQueued[mediaId] || exifInFlight[mediaId]));
2687    }
2688
2689    function hasDelayedPlaceholder(format) {
2690        return !!(format && (DESCRIPTION_PLACEHOLDER_RE.test(format) || EXIF_PLACEHOLDER_RE.test(format) || FILENAME_PLACEHOLDER_RE.test(format)));
2691    }
2692
2693    function hasMissingDelayedData(format, photo) {
2694        if (!format || !photo) {
2695            return false;
2696        }
2697        var derivedCamera = buildCameraDisplayName(photo.camera_make, photo.camera_model) || photo.camera || '';
2698        if (/\{description\}/.test(format) && !photo.description) {
2699            return true;
2700        }
2701        if (/\{camera\}/.test(format) && !derivedCamera) {
2702            return true;
2703        }
2704        if (/\{camera-make\}/.test(format) && !photo.camera_make) {
2705            return true;
2706        }
2707        if (/\{camera-model\}/.test(format) && !photo.camera_model) {
2708            return true;
2709        }
2710        if (/\{aperture\}/.test(format) && !photo.aperture) {
2711            return true;
2712        }
2713        if (/\{shutter\}/.test(format) && !photo.shutter) {
2714            return true;
2715        }
2716        if (/\{focal\}/.test(format) && !photo.focal) {
2717            return true;
2718        }
2719        if (/\{iso\}/.test(format) && !photo.iso) {
2720            return true;
2721        }
2722        if ((/\{filename\}/.test(format) || /\{name\}/.test(format)) && !photo.filename) {
2723            return true;
2724        }
2725        return false;
2726    }
2727
2728    function shouldShowPhotoMetaLoading(format, photo) {
2729        if (!format || !photo || !photo.id) {
2730            return false;
2731        }
2732        if (!isPhotoMetaPending(photo.id)) {
2733            return false;
2734        }
2735        if (!hasDelayedPlaceholder(format)) {
2736            return false;
2737        }
2738        return hasMissingDelayedData(format, photo);
2739    }
2740
2741    function getPhotoMetaLoadingText() {
2742        return 'Loading...';
2743    }
2744
2745    /**
2746     * Read info box format strings from a container's data attributes.
2747     *
2748     * @param {jQuery} $container Gallery container element.
2749     * @return {Object} { inline: { 'info-top': '...', ... }, fullscreen: { ... } }
2750     */
2751    function readInfoZoneFormats($container) {
2752        var inline = {};
2753        var fullscreen = {};
2754        var lightbox = {};
2755        for (var i = 0; i < INFO_BOX_NAMES.length; i++) {
2756            var zone = INFO_BOX_NAMES[i];
2757            inline[zone] = $container.attr('data-' + zone) || '';
2758            fullscreen[zone] = $container.attr('data-fullscreen-' + zone) || inline[zone];
2759            lightbox[zone] = $container.attr('data-lightbox-' + zone) || inline[zone];
2760        }
2761        return { inline: inline, fullscreen: fullscreen, lightbox: lightbox };
2762    }
2763
2764    function getInfoZoneFormat(zoneFormats, zone, viewerMode) {
2765        if (!zoneFormats) {
2766            return '';
2767        }
2768        if (viewerMode === 'lightbox') {
2769            return (zoneFormats.lightbox && zoneFormats.lightbox[zone]) ||
2770                (zoneFormats.inline && zoneFormats.inline[zone]) || '';
2771        }
2772        if (viewerMode) {
2773            return (zoneFormats.fullscreen && zoneFormats.fullscreen[zone]) ||
2774                (zoneFormats.inline && zoneFormats.inline[zone]) || '';
2775        }
2776        return (zoneFormats.inline && zoneFormats.inline[zone]) || '';
2777    }
2778
2779    function buildInfoBoxHtml(zone, inlineText, fullscreenText, lightboxText) {
2780        return '<div class="jzsa-info-box jzsa-' + zone + '"' +
2781            ' data-inline="' + escapeHtml(inlineText) + '"' +
2782            ' data-fullscreen="' + escapeHtml(fullscreenText) + '"' +
2783            ' data-lightbox="' + escapeHtml(lightboxText) + '"' +
2784            (inlineText ? '' : ' style="display:none"') + '>
2784' +
2785            escapeHtml(inlineText) + '</div>';
2786    }
2787
2788    function buildInfoStackHtml(order, zoneFormats, context, photo) {
2789        var html = '';
2790        for (var i = 0; i < order.length; i++) {
2791            var zone = order[i];
2792            var inlineFormat = getInfoZoneFormat(zoneFormats, zone, false);
2793            var fullscreenFormat = getInfoZoneFormat(zoneFormats, zone, true);
2794            var lightboxFormat = getInfoZoneFormat(zoneFormats, zone, 'lightbox');
2795            if (!inlineFormat && !fullscreenFormat && !lightboxFormat) {
2796                continue;
2797            }
2798            html += buildInfoBoxHtml(
2799                zone,
2800                resolveInfoPlaceholders(inlineFormat, photo, context),
2801                resolveInfoPlaceholders(fullscreenFormat, photo, context),
2802                resolveInfoPlaceholders(lightboxFormat, photo, context)
2803            );
2804        }
2805        return html;
2806    }
2807
2808    /**
2809     * Build info box overlay HTML for a single slide/thumbnail.
2810     *
2811     * @param {Object} photo       Photo data object.
2812     * @param {Object} zoneFormats Inline zone formats.
2813     * @param {Object} fsFormats   Fullscreen zone formats.
2814     * @return {string} HTML string with grouped zone divs.
2815     */
2816    // In gallery thumbnails info-bottom renders as a regular info box (no Swiper pill).
2817    var GALLERY_INFO_BOTTOM_ORDER = ['info-bottom'];
2818
2819    function buildInfoZoneHtml(photo, zoneFormats, fsFormats, context, options) {
2820        var opts = options || {};
2821        var bottomOrder = opts.bottomOrder || SAFE_INFO_BOTTOM_ORDER;
2822        var mergedZoneFormats = {
2823            inline: zoneFormats || {},
2824            fullscreen: fsFormats || {}
2825        };
2826        var topHtml = buildInfoStackHtml(SAFE_INFO_TOP_ORDER, mergedZoneFormats, context, photo);
2827        var bottomHtml = buildInfoStackHtml(bottomOrder, mergedZoneFormats, context, photo);
2828        return (topHtml ? '<div class="jzsa-info-stack jzsa-info-stack-top">' + topHtml + '</div>' : '') +
2829            (bottomHtml ? '<div class="jzsa-info-stack jzsa-info-stack-bottom">' + bottomHtml + '</div>' : '');
2830    }
2831
2832    function getInfoMeasureContext(fontSizePx, fontFamily) {
2833        if (!infoMeasureCanvas) {
2834            infoMeasureCanvas = document.createElement('canvas');
2835        }
2836        var ctx = infoMeasureCanvas.getContext('2d');
2837        ctx.font = fontSizePx + 'px ' + (fontFamily || DEFAULT_INFO_FONT_FAMILY);
2838        return ctx;
2839    }
2840
2841    function estimateInfoBoxWidth(text, fontSizePx, fontFamily) {
2842        if (!text) {
2843            return 0;
2844        }
2845        var ctx = getInfoMeasureContext(fontSizePx, fontFamily);
2846        return Math.ceil(ctx.measureText(text).width) + 24;
2847    }
2848
2849    function getInfoFontSizePx($container, attrName, fallbackPx) {
2850        var attrValue = parseInt($container.attr(attrName || 'data-info-font-size'), 10);
2851        if (!isNaN(attrValue) && attrValue > 0) {
2852            return attrValue;
2853        }
2854        return (typeof fallbackPx === 'number' && fallbackPx > 0) ? fallbackPx : 12;
2855    }
2856
2857    function getInfoFontFamily($container, attrName, fallbackFamily) {
2858        var attrValue = ($container.attr(attrName || 'data-info-font-family') || '').trim();
2859        if (attrValue) {
2860            return attrValue;
2861        }
2862        return (typeof fallbackFamily === 'string') ? fallbackFamily : '';
2863    }
2864
2865    function getInfoFontColor($container, attrName, fallbackColor) {
2866        var attrValue = ($container.attr(attrName || 'data-info-font-color') || '').trim();
2867        if (attrValue) {
2868            return attrValue;
2869        }
2870        return (typeof fallbackColor === 'string') ? fallbackColor : '';
2871    }
2872
2873    // ========================================================================
2874    // Wave 2: Background photo metadata prefetch via AJAX
2875    // ========================================================================
2876
2877    var DESCRIPTION_PLACEHOLDER_RE = /\{description\}/;
2878    var RAW_CAMERA_PLACEHOLDER_RE = /\{(?:camera-make|camera-model)\}/;
2879    var EXIF_PLACEHOLDER_RE = /\{(?:camera|camera-make|camera-model|aperture|shutter|focal|iso)\}/;
2880    var FILENAME_PLACEHOLDER_RE = /\{(?:filename|name)\}/;
2881    var EXIF_PREFETCH_WORKERS = 3;
2882    var PHOTO_META_LOADING_DELAY_MS = 250;
2883    var exifCache = {};          // mediaId -> cached photo-page metadata
2884    var exifQueue = [];          // { mediaId, photoUrl }
2885    var exifQueued = {};         // mediaId -> true
2886    var exifInFlight = {};       // mediaId -> true
2887    var exifWaiters = {};        // mediaId -> [ { $container, zoneFormats, photoIndex } ]
2888    var exifLoadingTimers = {};  // mediaId -> timeout id
2889    var exifPrefetchActive = 0;
2890    var exifPrefetchScheduled = false;
2891
2892    /**
2893     * Check whether any zone format string references Wave 2 metadata placeholders.
2894     */
2895    function getPhotoMetaNeeds(zoneFormats) {
2896        if (!zoneFormats) {
2897            return { description: false, rawCamera: false, exif: false, filename: false };
2898        }
2899        var all = zoneFormats.inline || {};
2900        var fs  = zoneFormats.fullscreen || {};
2901        var lb  = zoneFormats.lightbox || {};
2902        var needs = {
2903            description: false,
2904            rawCamera: false,
2905            exif: false,
2906            filename: false
2907        };
2908        for (var i = 0; i < INFO_BOX_NAMES.length; i++) {
2909            var zone = INFO_BOX_NAMES[i];
2910            needs.description = needs.description || DESCRIPTION_PLACEHOLDER_RE.test(all[zone] || '') || DESCRIPTION_PLACEHOLDER_RE.test(fs[zone] || '') || DESCRIPTION_PLACEHOLDER_RE.test(lb[zone] || '');
2911            needs.rawCamera = needs.rawCamera || RAW_CAMERA_PLACEHOLDER_RE.test(all[zone] || '') || RAW_CAMERA_PLACEHOLDER_RE.test(fs[zone] || '') || RAW_CAMERA_PLACEHOLDER_RE.test(lb[zone] || '');
2912            needs.exif = needs.exif || EXIF_PLACEHOLDER_RE.test(all[zone] || '') || EXIF_PLACEHOLDER_RE.test(fs[zone] || '') || EXIF_PLACEHOLDER_RE.test(lb[zone] || '');
2913            needs.filename = needs.filename || FILENAME_PLACEHOLDER_RE.test(all[zone] || '') || FILENAME_PLACEHOLDER_RE.test(fs[zone] || '') || FILENAME_PLACEHOLDER_RE.test(lb[zone] || '');
2914        }
2915        return needs;
2916    }
2917
2918    /**
2919     * Build the individual photo page URL from the album URL and media ID.
2920     */
2921    function buildPhotoPageUrl(albumUrl, mediaId) {
2922        if (!albumUrl || !mediaId) {
2923            return '';
2924        }
2925        var match = albumUrl.match(/\/share\/([^?]+)\?key=([^&"]+)/);
2926        if (!match) {
2927            return '';
2928        }
2929        return 'https://photos.google.com/share/' + match[1] + '/photo/' + mediaId + '?key=' + match[2];
2930    }
2931
2932    /**
2933     * After EXIF arrives for a photo, merge into the allPhotos array and
2934     * trigger a container-level info box refresh.
2935     */
2936    function applyExifToPhoto(mediaId, exifData, $container, photoIndex) {
2937        if (!exifData) {
2938            return;
2939        }
2940
2941        var photos = getContainerPhotos($container);
2942        var localIndex = findPhotoLocalIndex(photos, photoIndex);
2943        if (localIndex >= 0) {
2944            var p = photos[localIndex];
2945            var changed = false;
2946            var fields = ['description', 'camera', 'camera_make', 'camera_model', 'aperture', 'shutter', 'focal', 'iso', 'filename'];
2947            for (var f = 0; f < fields.length; f++) {
2948                if (exifData[fields[f]]) {
2949                    p[fields[f]] = exifData[fields[f]];
2950                    changed = true;
2951                }
2952            }
2953            var derivedCamera = buildCameraDisplayName(p.camera_make, p.camera_model);
2954            if (derivedCamera && p.camera !== derivedCamera) {
2955                p.camera = derivedCamera;
2956                changed = true;
2957            }
2958            if (changed) {
2959                replaceContainerPhotoByGlobalIndex($container, photoIndex, p);
2960                // Trigger container-level info box refresh.
2961                // Info boxes are container-level (not per-slide), so we
2962                // fire a custom event that updateAllInfoBoxes listens on.
2963                $container.trigger('jzsa:exif-update');
2964            }
2965        }
2966    }
2967
2968    function addExifWaiter(mediaId, $container, photoIndex) {
2969        if (!mediaId || !$container || !$container.length) {
2970            return;
2971        }
2972
2973        if (!exifWaiters[mediaId]) {
2974            exifWaiters[mediaId] = [];
2975        }
2976
2977        exifWaiters[mediaId].push({
2978            $container: $container,
2979            photoIndex: photoIndex
2980        });
2981
2982        if (!exifLoadingTimers[mediaId]) {
2983            exifLoadingTimers[mediaId] = window.setTimeout(function() {
2984                delete exifLoadingTimers[mediaId];
2985                if (!isPhotoMetaPending(mediaId)) {
2986                    return;
2987                }
2988                var waiters = exifWaiters[mediaId] || [];
2989                for (var i = 0; i < waiters.length; i++) {
2990                    waiters[i].$container.trigger('jzsa:exif-update');
2991                }
2992            }, PHOTO_META_LOADING_DELAY_MS);
2993        }
2994    }
2995
2996    function flushExifWaiters(mediaId, exifData) {
2997        var waiters = exifWaiters[mediaId] || [];
2998        delete exifWaiters[mediaId];
2999
3000        if (exifLoadingTimers[mediaId]) {
3001            clearTimeout(exifLoadingTimers[mediaId]);
3002            delete exifLoadingTimers[mediaId];
3003        }
3004
3005        for (var i = 0; i < waiters.length; i++) {
3006            if (exifData) {
3007                applyExifToPhoto(mediaId, exifData, waiters[i].$container, waiters[i].photoIndex);
3008            }
3009            waiters[i].$container.trigger('jzsa:exif-update');
3010        }
3011    }
3012
3013    function processExifQueue() {
3014        while (exifPrefetchActive < EXIF_PREFETCH_WORKERS && exifQueue.length) {
3015            var item = exifQueue.shift();
3016            delete exifQueued[item.mediaId];
3017            if (exifCache[item.mediaId] || exifInFlight[item.mediaId]) {
3018                if (exifCache[item.mediaId]) {
3019                    flushExifWaiters(item.mediaId, exifCache[item.mediaId]);
3020                }
3021                continue;
3022            }
3023
3024            if (!item.photoUrl) {
3025                continue;
3026            }
3027
3028            exifPrefetchActive++;
3029            exifInFlight[item.mediaId] = true;
3030
3031            (function(queueItem) {
3032                $.ajax({
3033                    url: jzsaAjax.ajaxUrl,
3034                    type: 'POST',
3035                    data: {
3036                        action: 'jzsa_fetch_photo_meta',
3037                        nonce: jzsaAjax.photoMetaNonce,
3038                        photo_url: queueItem.photoUrl,
3039                        media_url: queueItem.mediaUrl,
3040                        media_type: queueItem.mediaType,
3041                        need_description: queueItem.needDescription ? 'true' : 'false',
3042                        need_exif: queueItem.needExif ? 'true' : 'false',
3043                        need_filename: queueItem.needFilename ? 'true' : 'false'
3044                    }
3045                }).done(function(response) {
3046                    if (response && response.success && response.data) {
3047                        exifCache[queueItem.mediaId] = $.extend({}, exifCache[queueItem.mediaId] || {}, response.data);
3048                        if (exifCache[queueItem.mediaId]) {
3049                            var derivedCamera = buildCameraDisplayName(
3050                                exifCache[queueItem.mediaId].camera_make,
3051                                exifCache[queueItem.mediaId].camera_model
3052                            );
3053                            if (derivedCamera) {
3054                                exifCache[queueItem.mediaId].camera = derivedCamera;
3055                            }
3056                        }
3057                        flushExifWaiters(queueItem.mediaId, response.data);
3058                    }
3059                }).always(function() {
3060                    if (exifCache[queueItem.mediaId]) {
3061                        flushExifWaiters(queueItem.mediaId, exifCache[queueItem.mediaId]);
3062                    }
3063                    delete exifInFlight[queueItem.mediaId];
3064                    exifPrefetchActive = Math.max(0, exifPrefetchActive - 1);
3065                    processExifQueue();
3066                });
3067            })(item);
3068        }
3069    }
3070
3071    /**
3072     * Schedule Wave 2 metadata prefetch for all photos in a container.
3073     * Only runs if zone format strings reference delayed photo-page placeholders.
3074     *
3075     * @param {jQuery} $container Gallery/album container.
3076     * @param {Object} zoneFormats  { inline: {...}, fullscreen: {...} }
3077     */
3078    function scheduleExifPrefetch($container, zoneFormats) {
3079        var metaNeeds = getPhotoMetaNeeds(zoneFormats);
3080        if (!metaNeeds.description && !metaNeeds.exif && !metaNeeds.filename) {
3081            return;
3082        }
3083        if (typeof jzsaAjax === 'undefined' || !jzsaAjax.photoMetaNonce) {
3084            return;
3085        }
3086
3087        var albumUrl = $container.attr('data-album-url') || '';
3088        if (!albumUrl) {
3089            return;
3090        }
3091
3092        var photosJson = $container.attr('data-all-photos');
3093        if (!photosJson) {
3094            return;
3095        }
3096
3097        try {
3098            var photos = JSON.parse(photosJson);
3099            for (var i = 0; i < photos.length; i++) {
3100                var p = photos[i];
3101                var photoGlobalIndex = getPhotoGlobalIndex(p, i);
3102                var cachedMeta = exifCache[p.id] || {};
3103                var needsExifForPhoto = metaNeeds.exif && (
3104                    (!p.camera && !cachedMeta.camera) ||
3105                    (!p.camera_make && !cachedMeta.camera_make) ||
3106                    (!p.camera_model && !cachedMeta.camera_model) ||
3107                    (!p.aperture && !cachedMeta.aperture) ||
3108                    (!p.shutter && !cachedMeta.shutter) ||
3109                    (!p.focal && !cachedMeta.focal) ||
3110                    (!p.iso && !cachedMeta.iso)
3111                );
3112                var needsRawCameraForPhoto = metaNeeds.rawCamera && (
3113                    (!p.camera_make && !cachedMeta.camera_make) ||
3114                    (!p.camera_model && !cachedMeta.camera_model)
3115                );
3116                var needsDescriptionForPhoto = metaNeeds.description && !p.description && !cachedMeta.description;
3117                var needsFilenameForPhoto = metaNeeds.filename && !p.filename && !cachedMeta.filename;
3118
3119                if (exifCache[p.id]) {
3120                    (function(mediaId, cachedMeta, containerEl, photoIndex) {
3121                        window.setTimeout(function() {
3122                            applyExifToPhoto(mediaId, cachedMeta, containerEl, photoIndex);
3123                        }, 0);
3124                    })(p.id, exifCache[p.id], $container, photoGlobalIndex);
3125                }
3126
3127                if (!p.id || (!needsExifForPhoto && !needsRawCameraForPhoto && !needsDescriptionForPhoto && !needsFilenameForPhoto)) {
3128                    continue;
3129                }
3130
3131                addExifWaiter(p.id, $container, photoGlobalIndex);
3132
3133                if (exifQueued[p.id] || exifInFlight[p.id]) {
3134                    continue;
3135                }
3136                var photoUrl = buildPhotoPageUrl(albumUrl, p.id);
3137                if (!photoUrl) {
3138                    delete exifWaiters[p.id];
3139                    continue;
3140                }
3141                exifQueued[p.id] = true;
3142                exifQueue.push({
3143                    mediaId: p.id,
3144                    photoUrl: photoUrl,
3145                    mediaUrl: p.type === 'video' ? (p.video || '') : (p.full || p.preview || ''),
3146                    mediaType: p.type === 'video' ? 'video' : 'photo',
3147                    needDescription: needsDescriptionForPhoto,
3148                    needExif: needsExifForPhoto || needsRawCameraForPhoto || needsDescriptionForPhoto,
3149                    needFilename: needsFilenameForPhoto
3150                });
3151            }
3152        } catch (e) { /* ignore parse errors */ }
3153
3154        if (!exifQueue.length || exifPrefetchScheduled) {
3155            return;
3156        }
3157        exifPrefetchScheduled = true;
3158
3159        var schedule = window.requestIdleCallback || function(cb) { setTimeout(cb, 500); };
3160        schedule(function() {
3161            exifPrefetchScheduled = false;
3162            processExifQueue();
3163        });
3164    }
3165
3166    function getSwiperPhotoIndex(swiper) {
3167        if (!swiper) {
3168            return 0;
3169        }
3170
3171        if (
3172            swiper.slides &&
3173            typeof swiper.activeIndex === 'number' &&
3174            swiper.activeIndex >= 0 &&
3175            swiper.slides[swiper.activeIndex]
3176        ) {
3177            var globalIndexAttr = $(swiper.slides[swiper.activeIndex]).attr('data-jzsa-global-index');
3178            var parsedGlobalIndex = parseInt(globalIndexAttr, 10);
3179            if (!isNaN(parsedGlobalIndex) && parsedGlobalIndex >= 0) {
3180                return parsedGlobalIndex;
3181            }
3182
3183            var realIndexAttr = $(swiper.slides[swiper.activeIndex]).attr('data-swiper-slide-index');
3184            var parsedRealIndex = parseInt(realIndexAttr, 10);
3185            if (!isNaN(parsedRealIndex) && parsedRealIndex >= 0) {
3186                return parsedRealIndex;
3187            }
3188        }
3189
3190        if (typeof swiper.realIndex === 'number' && !isNaN(swiper.realIndex)) {
3191            return swiper.realIndex;
3192        }
3193
3194        if (typeof swiper.activeIndex === 'number' && swiper.activeIndex >= 0) {
3195            return swiper.activeIndex;
3196        }
3197
3198        return 0;
3199    }
3200
3201    function getContainerPhoto($container, photoIndex) {
3202        return getContainerPhotoByGlobalIndex($container, photoIndex);
3203    }
3204
3205    // Returns {item, items} strings for the container-level pagination pill.
3206    // In carousel mode, {item} may be a range like "16-17" when multiple slides are visible.
3207    function buildItemPlaceholders(mode, swiper, current, total) {
3208        if (mode === 'carousel') {
3209            var slidesPerView = swiper.params.slidesPerView || 1;
3210            var realIndex = getSwiperPhotoIndex(swiper);
3211            var visible = [];
3212            var maxVisible = Math.min(slidesPerView, total);
3213            for (var i = 0; i < maxVisible; i++) {
3214                var idx = realIndex + i;
3215                if (idx >= total) {
3216                    idx = swiper.params.loop ? idx % total : -1;
3217                }
3218                if (idx >= 0) {
3219                    visible.push(idx + 1);
3220                }
3221            }
3222            var itemStr = visible.length <= 1
3223                ? String(visible[0] || current)
3224                : visible[0] + '-' + visible[visible.length - 1];
3225            return { item: itemStr, items: String(total) };
3226        }
3227
3228        return { item: String(current), items: String(total) };
3229    }
3230
3231    // Returns {item, items} strings for a single photo at a 0-based index.
3232    function buildSinglePhotoItemPlaceholders(index, total) {
3233        var safeIndex = typeof index === 'number' && index >= 0 ? index + 1 : 1;
3234        return { item: String(safeIndex), items: String(total) };
3235    }
3236
3237    function renderInfoFormat(format, photo, context) {
3238        if (shouldShowPhotoMetaLoading(format, photo)) {
3239            return getPhotoMetaLoadingText();
3240        }
3241        return resolveInfoPlaceholders(format, photo, context);
3242    }
3243
3244    function refreshSwiperInfoBottom(swiper) {
3245        if (!swiper || swiper.destroyed || !swiper.pagination) {
3246            return;
3247        }
3248        if (typeof swiper.pagination.render === 'function') {
3249            swiper.pagination.render();
3250        }
3251        if (typeof swiper.pagination.update === 'function') {
3252            swiper.pagination.update();
3253        }
3254    }
3255
3256    function resolveCarouselTileInfo(photo, zoneFormats, context) {
3257        if (!zoneFormats) {
3258            return {};
3259        }
3260
3261        var info = {};
3262        for (var i = 0; i < SAFE_INFO_TOP_ORDER.length; i++) {
3263            info[SAFE_INFO_TOP_ORDER[i]] = resolveInfoPlaceholders(getInfoZoneFormat(zoneFormats, SAFE_INFO_TOP_ORDER[i], false), photo, context);
3264        }
3265        for (var j = 0; j < SAFE_INFO_BOTTOM_ORDER.length; j++) {
3266            info[SAFE_INFO_BOTTOM_ORDER[j]] = resolveInfoPlaceholders(getInfoZoneFormat(zoneFormats, SAFE_INFO_BOTTOM_ORDER[j], false), photo, context);
3267        }
3268        info['info-bottom'] = resolveInfoPlaceholders(getInfoZoneFormat(zoneFormats, 'info-bottom', false), photo, context);
3269        return info;
3270    }
3271
3272    function buildCarouselTileInfoHtml(photo, zoneFormats, context) {
3273        if (!zoneFormats) {
3274            return '';
3275        }
3276
3277        var tileInfo = resolveCarouselTileInfo(photo, zoneFormats, context);
3278        var html = '';
3279
3280        function buildZoneBox(zone) {
3281            var format = getInfoZoneFormat(zoneFormats, zone, false);
3282            if (!format) {
3283                return '';
3284            }
3285            var text = tileInfo[zone] || '';
3286            return '<div class="jzsa-info-box jzsa-' + zone + ' jzsa-carousel-tile-info-box"' +
3287                (text ? '' : ' style="display:none"') + '>' +
3288                escapeHtml(text) + '</div>';
3289        }
3290
3291        var topHtml = '';
3292        for (var i = 0; i < SAFE_INFO_TOP_ORDER.length; i++) {
3293            topHtml += buildZoneBox(SAFE_INFO_TOP_ORDER[i]);
3294        }
3295        if (topHtml) {
3296            html += '<div class="jzsa-info-stack jzsa-info-stack-top jzsa-carousel-tile-info-stack">' + topHtml + '</div>';
3297        }
3298
3299        // info-bottom is emitted as a standalone element so it can stay
3300        // pinned at the tile bottom independently of the  stack.
3301        var bottomSideHtml = '';
3302        for (var j = 0; j < SAFE_INFO_BOTTOM_ORDER.length; j++) {
3303            bottomSideHtml += buildZoneBox(SAFE_INFO_BOTTOM_ORDER[j]);
3304        }
3305        if (bottomSideHtml) {
3306            html += '<div class="jzsa-info-stack jzsa-info-stack-bottom jzsa-carousel-tile-info-stack">' + bottomSideHtml + '</div>';
3307        }
3308        html += buildZoneBox('info-bottom');
3309
3310        return html;
3311    }
3312
3313    // Helper: Build slides HTML structure (for photo/video array)
3314    function buildSlidesHtml(photos, options) {
3315        var config = options || {};
3316        var useLazyHints = !!config.lazyHints;
3317        var eagerIndex = typeof config.eagerIndex === 'number' ? config.eagerIndex : 0;
3318        var mode = config.mode || '';
3319        var showCarouselTileFullscreenButtons = !!config.showCarouselTileFullscreenButtons;
3320        var showCarouselTileLinkButtons = !!config.showCarouselTileLinkButtons;
3321        var showCarouselTileDownloadButtons = !!config.showCarouselTileDownloadButtons;
3322        var carouselLightboxEnabled = !!config.carouselLightboxEnabled;
3323        var carouselFsEnabled = !!config.carouselFsEnabled;
3324        var carouselDualExpand = carouselLightboxEnabled && carouselFsEnabled;
3325        var carouselAlbumUrl = config.carouselAlbumUrl || '';
3326        var carouselZoneFormats = config.carouselZoneFormats || null;
3327        var carouselTotalCount = parseInt(config.carouselTotalCount, 10) || photos.length || 0;
3328        var carouselAlbumTitle = config.carouselAlbumTitle || '';
3329        var html = '';
3330        photos.forEach(function(photo, index) {
3331            var globalIndex = getPhotoGlobalIndex(photo, index);
3332            var isVideo = photo.type === 'video';
3333            var tileOverlayButtons = '';
3334            var tileInfoHtml = '';
3335            if (mode === 'carousel') {
3336                var showTileLink = showCarouselTileLinkButtons && !!carouselAlbumUrl;
3337                var showTileDownload = showCarouselTileDownloadButtons;
3338                if (showCarouselTileFullscreenButtons) {
3339                    if (carouselDualExpand) {
3340                        tileOverlayButtons +=
3341                            '<button class="swiper-button-lightbox jzsa-gallery-thumb-fs-btn jzsa-carousel-slide-overlay-btn" type="button" ' +
3342                            'aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('openMediaLightbox', index + 1)) + '"></button>';
3343                        tileOverlayButtons +=
3344                            '<button class="swiper-button-fullscreen jzsa-gallery-thumb-fs-btn jzsa-carousel-slide-overlay-btn jzsa-carousel-slide-fs-btn" type="button" ' +
3345                            'aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('openMediaFullscreen', index + 1)) + '"></button>';
3346                    } else {
3347                        var tileClass = carouselLightboxEnabled ? 'swiper-button-lightbox' : 'swiper-button-fullscreen';
3348                        var tileAriaKey = carouselLightboxEnabled ? 'openMediaLightbox' : 'openMediaFullscreen';
3349                        tileOverlayButtons +=
3350                            '<button class="' + tileClass + ' jzsa-gallery-thumb-fs-btn jzsa-carousel-slide-overlay-btn jzsa-carousel-slide-fs-btn" type="button" ' +
3351                            'aria-label="' + jzsaEscapeAttr(jzsaFormatI18n(tileAriaKey, index + 1)) + '"></button>';
3352                    }
3353                }
3354                if (showTileLink) {
3355                    tileOverlayButtons +=
3356                        '<a href="' + carouselAlbumUrl + '" target="_blank" rel="noopener noreferrer" class="swiper-button-external-link jzsa-carousel-slide-overlay-btn jzsa-carousel-slide-link-btn jzsa-carousel-slide-left-primary" title="' + jzsaEscapeAttr(jzsaI18n('openInGooglePhotos')) + '" aria-label="' + jzsaEscapeAttr(jzsaI18n('openAlbumGooglePhotos')) + '"></a>';
3357                }
3358                if (showTileDownload) {
3359                    var downloadUrl = isVideo
3360                        ? (photo.video || photo.full || photo.preview || '')
3361                        : (photo.full || photo.preview || '');
3362                    var downloadPosClass = showTileLink ? 'jzsa-carousel-slide-left-secondary' : 'jzsa-carousel-slide-left-primary';
3363                    tileOverlayButtons +=
3364                        '<button class="swiper-button-download jzsa-carousel-slide-overlay-btn jzsa-carousel-slide-download-btn ' + downloadPosClass + '" type="button" data-download-url="' + downloadUrl + '" data-download-type="' + (isVideo ? 'video' : 'photo') + '" data-download-index="' + (globalIndex + 1) + '" title="' + jzsaEscapeAttr(jzsaI18n('downloadCurrentMedia')) + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('downloadMedia', globalIndex + 1)) + '"></button>';
3365                }
3366
3367                tileInfoHtml = buildCarouselTileInfoHtml(photo, carouselZoneFormats, $.extend(
3368                    buildSinglePhotoItemPlaceholders(globalIndex, carouselTotalCount),
3369                    { albumTitle: carouselAlbumTitle }
3370                ));
3371            }
3372
3373            // Build alt text from filename when available.
3374            var altText = photo.filename
3375                ? photo.filename.replace(/\.[^.]+$/, '')
3376                : 'Photo';
3377            if (isVideo) {
3378                var posterUrl = photo.preview || photo.full || '';
3379                html += '<div class="swiper-slide jzsa-slide-video" data-media-type="video" data-carousel-photo-index="' + globalIndex + '" data-jzsa-global-index="' + globalIndex + '">' +
3380                    buildVideoHtml({ src: photo.video, poster: posterUrl, mediaIndex: globalIndex }) +
3381                    tileInfoHtml +
3382                    tileOverlayButtons +
3383                    '</div>';
3384            } else {
3385                // Photo format: object with preview and full URLs
3386                var previewUrl = photo.preview || photo.full;
3387                var fullUrl = photo.full;
3388                var loadingAttr = '';
3389                if (useLazyHints) {
3390                    loadingAttr = ' loading="' + (index === eagerIndex ? 'eager' : 'lazy') + '" decoding="async"';
3391                }
3392
3393                html += '<div class="swiper-slide" data-carousel-photo-index="' + globalIndex + '" data-jzsa-global-index="' + globalIndex + '">' +
3394                    '<div class="swiper-zoom-container">' +
3395                    '<img src="' + previewUrl + '" ' +
3396                    (previewUrl !== fullUrl ? 'data-full-src="' + fullUrl + '" ' : '') +
3397                    'alt="' + escapeHtml(altText) + '" class="jzsa-progressive-image"' + loadingAttr + ' decoding="async" referrerpolicy="no-referrer" />' +
3398                    '</div>' +
3399                    tileInfoHtml +
3400                    tileOverlayButtons +
3401                    '</div>';
3402            }
3403        });
3404        return html;
3405    }
3406
3407    function updateCarouselTileInfoBoxes($container, swiper, zoneFormats) {
3408        if (!$container || !$container.length || !swiper) {
3409            return;
3410        }
3411        if ($container.attr('data-mode') !== 'carousel') {
3412            return;
3413        }
3414        if ($container.hasClass('jzsa-is-fullscreen') || $container.hasClass('jzsa-pseudo-fullscreen')) {
3415            return;
3416        }
3417
3418        var photosJson = $container.attr('data-all-photos');
3419        var photos = [];
3420        var total = getContainerTotalCount($container);
3421        if (photosJson) {
3422            try {
3423                photos = JSON.parse(photosJson);
3424            } catch (e) {}
3425        }
3426        var albumTitle = $container.attr('data-album-title') || '';
3427        var allInfoZones = SAFE_INFO_TOP_ORDER.concat(SAFE_INFO_BOTTOM_ORDER);
3428        $container.find('.swiper-slide').each(function() {
3429            var $slide = $(this);
3430            var photoIndex = parseInt($slide.attr('data-swiper-slide-index'), 10);
3431            if (isNaN(photoIndex) || photoIndex < 0) {
3432                photoIndex = parseInt($slide.attr('data-carousel-photo-index'), 10);
3433            }
3434            if (isNaN(photoIndex) || photoIndex < 0) {
3435                return;
3436            }
3437
3438            var photo = getContainerPhoto($container, photoIndex);
3439            $slide.find('.jzsa-carousel-tile-info-box').each(function() {
3440                var $box = $(this);
3441                var zone = '';
3442                for (var i = 0; i < allInfoZones.length; i++) {
3443                    if ($box.hasClass('jzsa-' + allInfoZones[i])) {
3444                        zone = allInfoZones[i];
3445                        break;
3446                    }
3447                }
3448                if (!zone) {
3449                    return;
3450                }
3451                var fmt = getInfoZoneFormat(zoneFormats, zone, false);
3452                var text = shouldShowPhotoMetaLoading(fmt, photo)
3453                    ? getPhotoMetaLoadingText()
3454                    : resolveInfoPlaceholders(fmt, photo,
3455                        $.extend(buildSinglePhotoItemPlaceholders(photoIndex, total), { albumTitle: albumTitle })
3456                    );
3457                $box.text(text).toggle(text !== '');
3458            });
3459        });
3460
3461    }
3462
3463    function updateGalleryThumbnailInfoBoxes($container) {
3464        if (!$container || !$container.length || $container.attr('data-mode') !== 'gallery') {
3465            return;
3466        }
3467
3468        var photosJson = $container.attr('data-all-photos');
3469        var photos = [];
3470        var total = getContainerTotalCount($container);
3471        if (photosJson) {
3472            try {
3473                photos = JSON.parse(photosJson);
3474            } catch (e) {}
3475        }
3476        var albumTitle = $container.attr('data-album-title') || '';
3477        var allInfoZones = SAFE_INFO_TOP_ORDER.concat(GALLERY_INFO_BOTTOM_ORDER);
3478
3479        $container.find('.jzsa-gallery-item, .jzsa-gallery-item-video').each(function() {
3480            var $item = $(this);
3481            var photoIndex = parseInt($item.attr('data-index'), 10);
3482            if (isNaN(photoIndex) || photoIndex < 0) {
3483                return;
3484            }
3485
3486            var photo = getContainerPhoto($container, photoIndex);
3487            $item.find('.jzsa-info-box').each(function() {
3488                var $box = $(this);
3489                var inlineFmt = '';
3490                for (var i = 0; i < allInfoZones.length; i++) {
3491                    if ($box.hasClass('jzsa-' + allInfoZones[i])) {
3492                        inlineFmt = $container.attr('data-' + allInfoZones[i]) || '';
3493                        break;
3494                    }
3495                }
3496                var text = shouldShowPhotoMetaLoading(inlineFmt, photo)
3497                    ? getPhotoMetaLoadingText()
3498                    : resolveInfoPlaceholders(inlineFmt, photo,
3499                        $.extend(buildSinglePhotoItemPlaceholders(photoIndex, total), { albumTitle: albumTitle })
3500                    );
3501                $box.text(text).toggle(text !== '');
3502            });
3503        });
3504
3505    }
3506
3507    // Helper: Build loading overlay markup.
3508    function buildLoaderHtml(text, slowText) {
3509        var label = text || 'Loading content...';
3510        var longWaitLabel = slowText || 'This is taking longer than usual...';
3511        return '' +
3512            '<div class="jzsa-loader" role="status" aria-live="polite" aria-atomic="true" aria-label="' + escapeHtml(label) + '" data-loader-default-text="' + escapeHtml(label) + '" data-loader-slow-text="' + escapeHtml(longWaitLabel) + '">' +
3513                '<div class="jzsa-loader-inner">' +
3514                    '<div class="jzsa-loader-spinner"></div>' +
3515                    '<div class="jzsa-loader-text" aria-hidden="true">' + escapeHtml(label) + '</div>' +
3516                '</div>' +
3517            '</div>';
3518    }
3519
3520    function clearLoaderTimers($container) {
3521        if (!$container || !$container.length) {
3522            return;
3523        }
3524
3525        var showTimer = $container.data('jzsaLoaderTextShowTimer');
3526        var slowTimer = $container.data('jzsaLoaderSlowTextTimer');
3527
3528        if (showTimer) {
3529            window.clearTimeout(showTimer);
3530            $container.removeData('jzsaLoaderTextShowTimer');
3531        }
3532
3533        if (slowTimer) {
3534            window.clearTimeout(slowTimer);
3535            $container.removeData('jzsaLoaderSlowTextTimer');
3536        }
3537    }
3538
3539    function resetLoaderState($container) {
3540        if (!$container || !$container.length) {
3541            return;
3542        }
3543
3544        clearLoaderTimers($container);
3545
3546        var $loader = $container.children('.jzsa-loader').first();
3547        if (!$loader.length) {
3548            return;
3549        }
3550
3551        var defaultText = $loader.attr('data-loader-default-text') || 'Loading content...';
3552        $loader
3553            .removeClass('jzsa-loader-text-visible jzsa-loader-text-slow')
3554            .attr('aria-label', defaultText);
3555        $loader.find('.jzsa-loader-text').text(defaultText);
3556    }
3557
3558    function activateLoaderState($container, text, slowText) {
3559        if (!$container || !$container.length) {
3560            return;
3561        }
3562
3563        var defaultText = text || 'Loading content...';
3564        var longWaitText = slowText || 'This is taking longer than usual...';
3565        var $loader = $container.children('.jzsa-loader').first();
3566
3567        if (!$loader.length) {
3568            $container.append(buildLoaderHtml(defaultText, longWaitText));
3569            $loader = $container.children('.jzsa-loader').first();
3570        }
3571
3572        $loader
3573            .attr('data-loader-default-text', defaultText)
3574            .attr('data-loader-slow-text', longWaitText);
3575
3576        resetLoaderState($container);
3577
3578        var showTimer = window.setTimeout(function() {
3579            var $activeLoader = $container.children('.jzsa-loader').first();
3580            if (!$activeLoader.length) {
3581                return;
3582            }
3583
3584            $activeLoader
3585                .removeClass('jzsa-loader-text-slow')
3586                .addClass('jzsa-loader-text-visible')
3587                .attr('aria-label', defaultText);
3588            $activeLoader.find('.jzsa-loader-text').text(defaultText);
3589        }, LOADER_TEXT_SHOW_DELAY_MS);
3590
3591        var slowTimer = window.setTimeout(function() {
3592            var $activeLoader = $container.children('.jzsa-loader').first();
3593            if (!$activeLoader.length) {
3594                return;
3595            }
3596
3597            $activeLoader
3598                .addClass('jzsa-loader-text-visible jzsa-loader-text-slow')
3599                .attr('aria-label', longWaitText);
3600            $activeLoader.find('.jzsa-loader-text').text(longWaitText);
3601        }, LOADER_SLOW_MESSAGE_DELAY_MS);
3602
3603        $container.data('jzsaLoaderTextShowTimer', showTimer);
3604        $container.data('jzsaLoaderSlowTextTimer', slowTimer);
3605    }
3606
3607    // Helper: Intro fade for gallery container so content appears progressively.
3608    function triggerGalleryIntroFade($container) {
3609        if (!$container || !$container.length) {
3610            return;
3611        }
3612
3613        var existingTimer = $container.data('jzsaIntroFadeTimer');
3614        if (existingTimer) {
3615            window.clearTimeout(existingTimer);
3616        }
3617
3618        $container
3619            .removeClass('jzsa-content-intro-visible')
3620            .addClass('jzsa-content-intro');
3621
3622        var introTimer = window.setTimeout(function() {
3623            $container.addClass('jzsa-content-intro-visible');
3624            $container.removeData('jzsaIntroFadeTimer');
3625        }, 20);
3626
3627        $container.data('jzsaIntroFadeTimer', introTimer);
3628    }
3629
3630    // Helper: Update video control auto-hide behavior for current and future playback.
3631    function applyVideoControlsAutohideSetting($container, autohide) {
3632        var hideControls = !!autohide;
3633        $container.attr('data-video-controls-autohide', hideControls ? 'true' : 'false');
3634
3635        // Best effort: update existing Plyr instances in-place.
3636        $container.find('video.jzsa-video-player').each(function() {
3637            var plyrRef = this._jzsaPlyr;
3638            if (!plyrRef) {
3639                return;
3640            }
3641            if (plyrRef.config) {
3642                plyrRef.config.hideControls = hideControls;
3643            }
3644            if (plyrRef.options) {
3645                plyrRef.options.hideControls = hideControls;
3646            }
3647        });
3648    }
3649
3650    // Helper: Apply inline/fullscreen display overrides that have sibling params.
3651    function applyFullscreenDisplayOverrides(containerElement, swiper, params, fullscreenActive) {
3652        if (!containerElement || !params) {
3653            return;
3654        }
3655
3656        var $container = $(containerElement);
3657        var useFullscreen = !!fullscreenActive;
3658
3659        var showNavigation = useFullscreen ? params.fullscreenShowNavigation : params.showNavigation;
3660        var controlsColor = useFullscreen ? params.fullscreenControlsColor : params.controlsColor;
3661        var videoControlsColor = useFullscreen ? params.fullscreenVideoControlsColor : params.videoControlsColor;
3662        var videoControlsAutohide = useFullscreen ? params.fullscreenVideoControlsAutohide : params.videoControlsAutohide;
3663        var infoFontSize = useFullscreen ? params.fullscreenInfoFontSize : params.infoFontSize;
3664        var infoFontFamily = useFullscreen ? params.fullscreenInfoFontFamily : params.infoFontFamily;
3665        var infoFontColor = useFullscreen ? params.fullscreenInfoFontColor : params.infoFontColor;
3666        var fullscreenDisplayMaxWidth = params.fullscreenDisplayMaxWidth;
3667        var fullscreenDisplayMaxHeight = params.fullscreenDisplayMaxHeight;
3668        var hasFullscreenDisplayLimits = useFullscreen && (
3669            (typeof fullscreenDisplayMaxWidth === 'number' && fullscreenDisplayMaxWidth > 0) ||
3670            (typeof fullscreenDisplayMaxHeight === 'number' && fullscreenDisplayMaxHeight > 0)
3671        );
3672
3673        $container.attr('data-show-navigation', showNavigation ? 'true' : 'false');
3674        $container.attr('data-has-fullscreen-display-limits', hasFullscreenDisplayLimits ? 'true' : 'false');
3675
3676        if (controlsColor) {
3677            containerElement.style.setProperty('--jzsa-controls-color', controlsColor);
3678            applyControlsColorToIcons('#' + (params.galleryId || $container.attr('id')), controlsColor);
3679        } else {
3680            containerElement.style.removeProperty('--jzsa-controls-color');
3681        }
3682
3683        if (videoControlsColor) {
3684            containerElement.style.setProperty('--jzsa-video-controls-color', videoControlsColor);
3685        } else {
3686            containerElement.style.removeProperty('--jzsa-video-controls-color');
3687        }
3688
3689        if (infoFontSize > 0) {
3690            containerElement.style.setProperty('--jzsa-info-font-size', infoFontSize + 'px');
3691        } else {
3692            containerElement.style.removeProperty('--jzsa-info-font-size');
3693        }
3694
3695        if (infoFontFamily) {
3696            containerElement.style.setProperty('--jzsa-info-font-family', infoFontFamily);
3697        } else {
3698            containerElement.style.removeProperty('--jzsa-info-font-family');
3699        }
3700
3701        if (infoFontColor) {
3702            containerElement.style.setProperty('--jzsa-info-font-color', infoFontColor);
3703        } else {
3704            containerElement.style.removeProperty('--jzsa-info-font-color');
3705        }
3706
3707        if (hasFullscreenDisplayLimits && typeof fullscreenDisplayMaxWidth === 'number' && fullscreenDisplayMaxWidth > 0) {
3708            containerElement.style.setProperty('--jzsa-fullscreen-display-max-width', fullscreenDisplayMaxWidth + 'px');
3709        } else {
3710            containerElement.style.removeProperty('--jzsa-fullscreen-display-max-width');
3711        }
3712
3713        if (hasFullscreenDisplayLimits && typeof fullscreenDisplayMaxHeight === 'number' && fullscreenDisplayMaxHeight > 0) {
3714            containerElement.style.setProperty('--jzsa-fullscreen-display-max-height', fullscreenDisplayMaxHeight + 'px');
3715        } else {
3716            containerElement.style.removeProperty('--jzsa-fullscreen-display-max-height');
3717        }
3718        if (useFullscreen && typeof params.fullscreenCornerRadius === 'number' && params.fullscreenCornerRadius >= 0) {
3719            containerElement.style.setProperty('--jzsa-fullscreen-corner-radius', params.fullscreenCornerRadius + 'px');
3720        } else {
3721            containerElement.style.removeProperty('--jzsa-fullscreen-corner-radius');
3722        }
3723
3724        var activeBottomCenterFormat = useFullscreen
3725            ? (isLightboxPresentationActive(containerElement)
3726                ? ($container.attr('data-lightbox-info-bottom') || $container.attr('data-info-bottom') || '')
3727                : ($container.attr('data-fullscreen-info-bottom') || $container.attr('data-info-bottom') || ''))
3728            : ($container.attr('data-info-bottom') || '');
3729        $container.attr('data-has-active-bottom-center', activeBottomCenterFormat ? 'true' : 'false');
3730
3731        applyVideoControlsAutohideSetting($container, videoControlsAutohide);
3732
3733        params.slideshowAutoresume = useFullscreen ? params.fullscreenSlideshowAutoresume : params.inlineSlideshowAutoresume;
3734
3735        if (swiper && swiper.pagination) {
3736            if (typeof swiper.pagination.render === 'function') {
3737                swiper.pagination.render();
3738            }
3739            if (typeof swiper.pagination.update === 'function') {
3740                swiper.pagination.update();
3741            }
3742        }
3743    }
3744
3745    // Helper: Apply fullscreen slideshow settings immediately (for Android compatibility)
3746    function applyFullscreenAutoplaySettings(swiper, params) {
3747        // Only run this workaround on Android devices where fullscreenchange events
3748        // are known to be unreliable.
3749		if (params.fullscreenSlideshow === 'disabled' || !isAndroid()) {
3750			return;
3751		}
3752
3753		jzsaDebug('🔍 Applying fullscreen autoplay settings immediately (Android workaround)');
3754
3755        // Stop current autoplay if running
3756        if (swiper.autoplay && swiper.autoplay.running) {
3757            swiper.autoplay.stop();
3758        }
3759
3760        // Update autoplay delay for fullscreen mode
3761			var newDelay = params.fullscreenSlideshowDelay * MILLISECONDS_PER_SECOND;
3762			jzsaDebug('🔍 Setting fullscreen autoplay delay to:', newDelay, 'ms (', params.fullscreenSlideshowDelay, 's)');
3763
3764        // Update both params and the active autoplay object
3765        swiper.params.autoplay.delay = newDelay;
3766        if (swiper.autoplay) {
3767            swiper.autoplay.delay = newDelay;
3768        }
3769
3770        // Start fullscreen autoplay after a short delay to ensure fullscreen is active
3771        setTimeout(function() {
3772				if (params.fullscreenSlideshow === 'auto' && swiper.autoplay) {
3773					params.slideshowPausedByInteraction = false;
3774					swiper.autoplay.start();
3775					jzsaDebug('▶️  Fullscreen autoplay started immediately (delay: ' + params.fullscreenSlideshowDelay + 's)');
3776				}
3777        }, 100);
3778
3779        // ANDROID WORKAROUND: Some Android browsers don't fire fullscreen change events reliably.
3780        // Poll for fullscreen state and apply settings if events don't fire within 300ms
3781			var fullscreenCheckTimeout = setTimeout(function() {
3782				var nowFullscreen = isFullscreen();
3783
3784				if (nowFullscreen && params.fullscreenSlideshow !== 'disabled') {
3785					jzsaDebug('⚠️  Fullscreen change event did not fire - applying settings via fallback (Android workaround)');
3786
3787                // Ensure settings are applied
3788                if (swiper.autoplay && swiper.autoplay.running) {
3789                    swiper.autoplay.stop();
3790                }
3791
3792                var newDelay = params.fullscreenSlideshowDelay * MILLISECONDS_PER_SECOND;
3793                swiper.params.autoplay.delay = newDelay;
3794                if (swiper.autoplay) {
3795                    swiper.autoplay.delay = newDelay;
3796                }
3797
3798					if (params.fullscreenSlideshow === 'auto') {
3799						params.slideshowPausedByInteraction = false;
3800						swiper.autoplay.start();
3801						jzsaDebug('▶️  Fullscreen autoplay started via fallback (delay: ' + params.fullscreenSlideshowDelay + 's)');
3802					}
3803            }
3804        }, 300);
3805
3806        // Clear the timeout if fullscreen change event fires (it will handle the settings)
3807        var clearFallback = function() {
3808            clearTimeout(fullscreenCheckTimeout);
3809            document.removeEventListener('fullscreenchange', clearFallback);
3810            document.removeEventListener('webkitfullscreenchange', clearFallback);
3811            document.removeEventListener('mozfullscreenchange', clearFallback);
3812            document.removeEventListener('MSFullscreenChange', clearFallback);
3813        };
3814        document.addEventListener('fullscreenchange', clearFallback);
3815        document.addEventListener('webkitfullscreenchange', clearFallback);
3816        document.addEventListener('mozfullscreenchange', clearFallback);
3817        document.addEventListener('MSFullscreenChange', clearFallback);
3818    }
3819
3820    // Helper: Handle fullscreen change events
3821    function handleFullscreenChange(containerElement, swiper, params) {
3822        // Only handle fullscreen changes for THIS gallery
3823        var fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement ||
3824                               document.mozFullScreenElement || document.msFullscreenElement;
3825        var isPseudoFullscreen = $(containerElement).hasClass('jzsa-pseudo-fullscreen');
3826        var isContainerFullscreen = fullscreenElement === containerElement || isPseudoFullscreen;
3827
3828        if (isContainerFullscreen) {
3829				// Entering fullscreen - switch to fullscreen autoplay settings
3830				var logPrefix = params.browserPrefix ? ' (' + params.browserPrefix + ')' : '';
3831				jzsaDebug('🔍 Fullscreen entered for gallery' + logPrefix + ':', params.galleryId);
3832            if (!params._fullscreenActive) {
3833                swiper._jzsaKeyboardOwnerBeforeFullscreen = keyboardOwnerGalleryId;
3834            }
3835            claimGalleryKeyboardOwner(containerElement.id);
3836            params._fullscreenActive = true;
3837            if (swiper && swiper._jzsaExpandedMosaicController) {
3838                swiper._jzsaExpandedMosaicController.useFullscreen();
3839            }
3840
3841            // In carousel mode, switch layout to a single slide in fullscreen
3842            // while keeping the preview in multi-slide carousel mode.
3843            if (params.mode === 'carousel') {
3844                if (params.originalSlidesPerView == null) {
3845                    params.originalSlidesPerView = swiper.params.slidesPerView;
3846                    params.originalBreakpoints = swiper.params.breakpoints;
3847                    params.originalCenteredSlides = swiper.params.centeredSlides;
3848                }
3849                swiper.params.slidesPerView = 1;
3850                swiper.params.centeredSlides = true;
3851                swiper.params.breakpoints = undefined;
3852                swiper.update();
3853            }
3854
3855            // Add fullscreen class for CSS styling
3856            $(containerElement).addClass('jzsa-is-fullscreen');
3857            applyFullscreenDisplayOverrides(containerElement, swiper, params, true);
3858
3859            // Apply fullscreen background color if set
3860            var fsBgColor = $(containerElement).attr('data-fullscreen-background-color');
3861            if (fsBgColor && !isPseudoFullscreen) {
3862                params._originalBgColor = containerElement.style.getPropertyValue('--gallery-bg-color');
3863                containerElement.style.setProperty('--gallery-bg-color', fsBgColor);
3864            }
3865
3866            if (!params.browserPrefix) {
3867                // Only log detailed debug info for standard API (avoid log spam)
3868                // console.log('🔍 fullscreenSlideshow:', params.fullscreenSlideshow);
3869                // console.log('🔍 fullscreenSlideshowDelay:', params.fullscreenSlideshowDelay);
3870            }
3871
3872            if (params.fullscreenSlideshow !== 'disabled') {
3873                // Stop current autoplay if running
3874                if (swiper.autoplay && swiper.autoplay.running) {
3875                    swiper.autoplay.stop();
3876                }
3877                // Update autoplay delay for fullscreen mode
3878                var newDelay = params.fullscreenSlideshowDelay * MILLISECONDS_PER_SECOND;
3879
3880			if (!params.browserPrefix) {
3881				jzsaDebug('🔍 Setting fullscreen autoplay delay to:', newDelay, 'ms (', params.fullscreenSlideshowDelay, 's)');
3882			}
3883
3884                // Update both params and the active autoplay object
3885                swiper.params.autoplay.delay = newDelay;
3886				if (swiper.autoplay) {
3887					swiper.autoplay.delay = newDelay;
3888				}
3889
3890				if (!params.browserPrefix) {
3891					jzsaDebug('🔍 swiper.params.autoplay.delay is now:', swiper.params.autoplay.delay);
3892					jzsaDebug('🔍 swiper.autoplay.delay is now:', swiper.autoplay ? swiper.autoplay.delay : 'N/A');
3893				}
3894
3895	                // Start fullscreen autoplay in 'auto' mode on entry, even if a
3896	                // previous inline/lightbox interaction left a stale pause flag.
3897					if (params.fullscreenSlideshow === 'auto') {
3898						params.slideshowPausedByInteraction = false;
3899						clearCountdownRing($(containerElement));
3900						swiper.autoplay.start();
3901						jzsaDebug('▶️  Fullscreen autoplay started (delay: ' + params.fullscreenSlideshowDelay + 's' + logPrefix + ')');
3902					}
3903	            }
3904        } else if (!fullscreenElement && swiper && params._fullscreenActive) {
3905				// Exiting fullscreen (this gallery was in fullscreen before) - switch back to normal autoplay settings
3906				var logPrefix = params.browserPrefix ? ' (' + params.browserPrefix + ')' : '';
3907				jzsaDebug('🔍 Fullscreen exited for gallery' + logPrefix + ':', params.galleryId);
3908
3909            // Exit via Esc/browser chrome does not go through toggleFullscreen().
3910            // Ensure video playback is stopped consistently on fullscreen exit.
3911            stopAllManagedVideos();
3912
3913            // In carousel mode, restore the original multi-slide layout
3914            // but keep the same logical photo index the user was viewing in
3915            // fullscreen. We must capture the realIndex *before* changing
3916            // slidesPerView/breakpoints, because Swiper may adjust indices when
3917            // layout changes.
3918            if (params.mode === 'carousel' && params.originalSlidesPerView != null) {
3919                var targetIndex = (typeof swiper.realIndex === 'number') ? swiper.realIndex : swiper.activeIndex;
3920
3921                swiper.params.slidesPerView = params.originalSlidesPerView;
3922                swiper.params.centeredSlides = params.originalCenteredSlides;
3923                swiper.params.breakpoints = params.originalBreakpoints;
3924                swiper.update();
3925
3926                if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
3927                    swiper.slideToLoop(targetIndex, 0, false);
3928                } else {
3929                    swiper.slideTo(targetIndex, 0, false);
3930                }
3931            }
3932
3933            // Attempt to auto-correct WordPress tile width if iOS Safari/WebKit
3934            // leaves it in a broken state after fullscreen. We compare the parent LI
3935            // width with the gallery container width and, if they differ
3936            // significantly, clamp the LI to match the gallery width.
3937            // Only apply this workaround on iOS devices (Safari and WebKit-based
3938            // browsers like Chrome on iOS all use the same engine).
3939            if (!isIosDevice()) {
3940                // Remove any stale no-scroll class even on non-iOS, just in case.
3941                $('html, body').removeClass('jzsa-no-scroll');
3942            }
3943
3944            // Poll a few times shortly after exiting fullscreen so we can
3945            // correct the width almost immediately after the buggy layout
3946            // kicks in (old iOS sometimes applies it with a delay).
3947            (function () {
3948                var attempts = 0;
3949                var maxAttempts = 6; // ~6 * 120ms ≈ 720ms total
3950                var interval = 120;
3951
3952                function tryFix() {
3953                    attempts++;
3954                    try {
3955                        if (!isIosDevice()) {
3956                            return;
3957                        }
3958
3959                        var $container = $(containerElement);
3960                        var liEl = $container.closest('li')[0];
3961                        if (!liEl || !containerElement || !containerElement.getBoundingClientRect) {
3962                            return;
3963                        }
3964
3965                        var galleryRect = containerElement.getBoundingClientRect();
3966                        var liRect = liEl.getBoundingClientRect();
3967
3968                        if (!galleryRect || !liRect) {
3969                            return;
3970                        }
3971
3972                        var gw = galleryRect.width;
3973                        var lw = liRect.width;
3974
3975                        // Only adjust if we have sane numbers and the LI is much
3976                        // narrower than the gallery (e.g. 35px vs 267p
3976x).
3977						if (gw > 0 && lw > 0 && Math.abs(lw - gw) > 20) {
3978							jzsaDebug('[JZSA LAYOUT FIX] correcting LI width for', params.galleryId, 'from', lw, 'to', gw, 'on attempt', attempts);
3979							liEl.style.width = gw + 'px';
3980                            return; // stop polling once fixed
3981                        }
3982					} catch (e) {
3983						jzsaDebug('[JZSA LAYOUT FIX ERROR]', e);
3984					}
3985
3986                    if (attempts < maxAttempts) {
3987                        setTimeout(tryFix, interval);
3988                    }
3989                }
3990
3991                setTimeout(tryFix, interval);
3992            })();
3993
3994            // When the user entered native fullscreen from within the lightbox,
3995            // exit should land back in the lightbox, not inline.
3996            var isReturningToLightbox = $(containerElement).hasClass('jzsa-lightbox-active');
3997
3998            // Restore original background color.
3999            // Skip for lightbox return; the lightbox manages the bg itself.
4000            if (!isReturningToLightbox && params._originalBgColor !== undefined) {
4001                if (params._originalBgColor) {
4002                    containerElement.style.setProperty('--gallery-bg-color', params._originalBgColor);
4003                } else {
4004                    containerElement.style.removeProperty('--gallery-bg-color');
4005                }
4006            }
4007            delete params._originalBgColor;
4008
4009            $(containerElement).removeClass('jzsa-fullscreen-waiting');
4010
4011            if (!isReturningToLightbox) {
4012                // Standard exit: remove fs class, revert display overrides, restore scroll.
4013                $(containerElement).removeClass('jzsa-is-fullscreen');
4014                applyFullscreenDisplayOverrides(containerElement, swiper, params, false);
4015                clearCountdownRing($(containerElement));
4016                notifyGalleryOnFullscreenExit(containerElement, swiper);
4017            } else {
4018                // Back in lightbox: keep jzsa-is-fullscreen (lightbox uses it),
4019                // re-apply lightbox display settings, skip scroll restore.
4020                var lightboxDisplayParams = swiper && swiper._jzsaLightboxDisplayParams;
4021                applyFullscreenDisplayOverrides(
4022                    containerElement,
4023                    swiper,
4024                    lightboxDisplayParams || params,
4025                    true
4026                );
4027                clearCountdownRing($(containerElement));
4028            }
4029
4030            params.slideshowPausedByInteraction = false;
4031
4032            if (isReturningToLightbox) {
4033                if (swiper && swiper._jzsaExpandedMosaicController) {
4034                    swiper._jzsaExpandedMosaicController.useLightbox();
4035                }
4036                // Restore lightbox slideshow after returning from native fullscreen.
4037                if (swiper.autoplay && swiper.autoplay.running) {
4038                    swiper.autoplay.stop();
4039                }
4040                if (params.lightboxSlideshow !== 'disabled') {
4041                    var lbDelay = params.lightboxSlideshowDelay * MILLISECONDS_PER_SECOND;
4042                    swiper.params.autoplay.delay = lbDelay;
4043                    if (swiper.autoplay) {
4044                        swiper.autoplay.delay = lbDelay;
4045                    }
4046                    if (params.lightboxSlideshow === 'auto') {
4047                        swiper.autoplay.start();
4048                    }
4049                }
4050            } else if (params.slideshow === 'auto') {
4051                // Stop current autoplay if running
4052                if (swiper.autoplay && swiper.autoplay.running) {
4053                    swiper.autoplay.stop();
4054                }
4055                // Restore normal autoplay delay
4056                var normalDelay = params.slideshowDelay * MILLISECONDS_PER_SECOND;
4057                swiper.params.autoplay.delay = normalDelay;
4058                if (swiper.autoplay) {
4059                    swiper.autoplay.delay = normalDelay;
4060                }
4061                // Start normal autoplay
4062                swiper.autoplay.start();
4063                // console.log('▶️  Normal autoplay restored (delay: ' + params.slideshowDelay + 's' + logPrefix + ')');
4064	            } else if (swiper.autoplay && swiper.autoplay.running) {
4065	                // Stop autoplay if inline slideshow is not 'auto'
4066	                swiper.autoplay.stop();
4067	                // console.log('⏸️  Autoplay stopped (not auto in normal mode' + logPrefix + ')');
4068	            }
4069            var keyboardOwnerBeforeFullscreen = swiper._jzsaKeyboardOwnerBeforeFullscreen;
4070            delete swiper._jzsaKeyboardOwnerBeforeFullscreen;
4071            if (isReturningToLightbox) {
4072                claimGalleryKeyboardOwner(containerElement.id);
4073            } else if (keyboardOwnerBeforeFullscreen && keyboardOwnerBeforeFullscreen !== containerElement.id) {
4074                restoreGalleryKeyboardOwner(keyboardOwnerBeforeFullscreen);
4075            } else if ($(containerElement).is(':visible')) {
4076                claimGalleryKeyboardOwner(containerElement.id);
4077            } else {
4078                restoreGalleryKeyboardOwner(null);
4079            }
4080            params._fullscreenActive = false;
4081	        }
4082	    }
4083
4084    // Helper: Show countdown ring on the play/pause button (appears after 5s delay)
4085    var COUNTDOWN_RING_DELAY_MS = 5000;
4086
4087    function showCountdownRing($el, durationSeconds) {
4088        var $btn = $el.find('.swiper-button-play-pause');
4089        $btn.find('.jzsa-countdown-ring').remove();
4090        clearTimeout($el.data('jzsa-countdown-show-timer'));
4091        $el.addClass('jzsa-slideshow-interrupted');
4092        $el.removeClass('jzsa-countdown-ring-visible');
4093
4094        var ns = 'http://www.w3.org/2000/svg';
4095        var svg = document.createElementNS(ns, 'svg');
4096        svg.setAttribute('class', 'jzsa-countdown-ring');
4097        svg.setAttribute('viewBox', '0 0 22 22');
4098        var circle = document.createElementNS(ns, 'circle');
4099        circle.setAttribute('cx', '11');
4100        circle.setAttribute('cy', '11');
4101        circle.setAttribute('r', '9');
4102        circle.style.animationDuration = durationSeconds + 's';
4103        svg.appendChild(circle);
4104        $btn.append(svg);
4105
4106        // Fade in after delay
4107        var showTimer = setTimeout(function() {
4108            $el.addClass('jzsa-countdown-ring-visible');
4109            $(svg).addClass('jzsa-visible');
4110        }, COUNTDOWN_RING_DELAY_MS);
4111        $el.data('jzsa-countdown-show-timer', showTimer);
4112    }
4113
4114    // Helper: Remove countdown ring from the play/pause button
4115    function clearCountdownRing($el) {
4116        clearTimeout($el.data('jzsa-countdown-show-timer'));
4117        $el.removeClass('jzsa-slideshow-interrupted');
4118        $el.removeClass('jzsa-countdown-ring-visible');
4119        $el.find('.swiper-button-play-pause .jzsa-countdown-ring').remove();
4120    }
4121
4122    // Helper: Pause slideshow on user interaction
4123    function pauseAutoplayOnInteraction(swiper, params) {
4124        // Act when autoplay is running OR already interrupted (to reset the countdown)
4125        if (swiper.autoplay && (swiper.autoplay.running || params.slideshowPausedByInteraction)) {
4126            if (swiper.autoplay.running) {
4127                swiper.autoplay.stop();
4128                jzsaDebug('⏸️  Autoplay paused by user interaction');
4129            } else {
4130                jzsaDebug('⏸️  Autoresume countdown reset by user interaction');
4131            }
4132            params.slideshowPausedByInteraction = true;
4133
4134            // Clear any existing inactivity timer
4135            if (params.inactivityTimer) {
4136                clearTimeout(params.inactivityTimer);
4137            }
4138
4139            // Set inactivity timer to resume autoplay after configured timeout
4140            if (params.slideshowAutoresume !== 'disabled') {
4141                var timeoutMs = (params.slideshowAutoresume || 30) * 1000;
4142                showCountdownRing($(swiper.el), params.slideshowAutoresume || 30);
4143                params.inactivityTimer = setTimeout(function() {
4144                    if (params.slideshowPausedByInteraction && swiper.autoplay && !swiper.autoplay.running) {
4145                        jzsaDebug('▶️  Resuming autoplay after ' + (params.slideshowAutoresume || 30) + ' seconds of inactivity');
4146                        params.slideshowPausedByInteraction = false;
4147                        clearCountdownRing($(swiper.el));
4148                        swiper.autoplay.start();
4149                    }
4150                }, timeoutMs);
4151            }
4152        }
4153    }
4154
4155
4156    // Helper: Setup fullscreen button
4157    function setupFullscreenButton(swiper, $container, params) {
4158        if (params && params.interactionLock) {
4159            return;
4160        }
4161
4162        // Dedicated lightbox button - opens the lightbox overlay directly.
4163        $container.find('.swiper-button-lightbox').on('click', function(e) {
4164            e.stopPropagation();
4165            // For carousel per-tile lightbox buttons, navigate to the exact clicked tile first.
4166            if (params.mode === 'carousel' && $(this).hasClass('jzsa-gallery-thumb-fs-btn')) {
4167                var $clickedSlide = $(this).closest('.swiper-slide');
4168                if ($clickedSlide.length) {
4169                    var realIndexAttr = $clickedSlide.attr('data-swiper-slide-index');
4170                    var realIndex = realIndexAttr != null ? parseInt(realIndexAttr, 10) : NaN;
4171                    if (!isNaN(realIndex) && realIndex >= 0) {
4172                        if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
4173                            swiper.slideToLoop(realIndex, 0, false);
4174                        } else {
4175                            swiper.slideTo(realIndex, 0, false);
4176                        }
4177                    }
4178                }
4179            }
4180            toggleLightbox($container[0], params.showHintsOnLightbox);
4181        });
4182
4183        var $fullscreenBtn = $container.find('.swiper-button-fullscreen');
4184		$fullscreenBtn.on('click', function(e) {
4185			e.stopPropagation();
4186
4187			// Check if we're entering or exiting fullscreen
4188			var isCurrentlyFullscreen = isFullscreen();
4189
4190			if (!isCurrentlyFullscreen) {
4191				// In carousel mode, navigate to the exact clicked tile first.
4192				if (params.mode === 'carousel') {
4193					var $clickedSlide = $(e.target).closest('.swiper-slide');
4194					if ($clickedSlide.length) {
4195						var realIndexAttr = $clickedSlide.attr('data-swiper-slide-index');
4196						var realIndex = realIndexAttr != null ? parseInt(realIndexAttr, 10) : NaN;
4197						if (!isNaN(realIndex) && realIndex >= 0) {
4198							if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
4199								swiper.slideToLoop(realIndex, 0, false);
4200							} else {
4201								swiper.slideTo(realIndex, 0, false);
4202							}
4203						}
4204					}
4205					}
4206
4207				// About to enter fullscreen - apply fullscreen autoplay settings immediately (Android workaround)
4208				jzsaDebug('🔍 Fullscreen button clicked - entering fullscreen');
4209                applyFullscreenAutoplaySettings(swiper, {
4210                    fullscreenSlideshow: params.fullscreenSlideshow,
4211                    fullscreenSlideshowDelay: params.fullscreenSlideshowDelay,
4212                    slideshowPausedByInteraction: params.slideshowPausedByInteraction
4213                });
4214            }
4215
4216            toggleFullscreen($container[0], params.showHintsOnFullscreen);
4217        });
4218    }
4219
4220    function normalizeDownloadErrorData(raw) {
4221        var message = '';
4222        var requiresLargeDownloadConfirmation = false;
4223        var actualSizeBytes = 0;
4224        var maxSizeBytes = 0;
4225
4226        if (typeof raw === 'string') {
4227            message = raw;
4228        } else if (raw && typeof raw === 'object') {
4229            if (typeof raw.message === 'string') {
4230                message = raw.message;
4231            }
4232            requiresLargeDownloadConfirmation = raw.requires_large_download_confirmation === true;
4233            if (raw.actual_size_bytes !== undefined && raw.actual_size_bytes !== null && !isNaN(parseInt(raw.actual_size_bytes, 10))) {
4234                actualSizeBytes = parseInt(raw.actual_size_bytes, 10);
4235            }
4236            if (raw.warning_size_bytes !== undefined && raw.warning_size_bytes !== null && !isNaN(parseInt(raw.warning_size_bytes, 10))) {
4237                maxSizeBytes = parseInt(raw.warning_size_bytes, 10);
4238            }
4239        }
4240
4241        return {
4242            message: message || 'Download failed. Please try again.',
4243            requiresLargeDownloadConfirmation: requiresLargeDownloadConfirmation,
4244            actualSizeBytes: actualSizeBytes,
4245            maxSizeBytes: maxSizeBytes
4246        };
4247    }
4248
4249    function getAjaxErrorData(xhr) {
4250        if (!xhr) {
4251            return null;
4252        }
4253        if (xhr.responseJSON && xhr.responseJSON.success === false) {
4254            return normalizeDownloadErrorData(xhr.responseJSON.data);
4255        }
4256        if (typeof xhr.responseText === 'string' && xhr.responseText) {
4257            try {
4258                var parsed = JSON.parse(xhr.responseText);
4259                if (parsed && parsed.success === false) {
4260                    return normalizeDownloadErrorData(parsed.data);
4261                }
4262            } catch (_ignore) {
4263                return null;
4264            }
4265        }
4266        return null;
4267    }
4268
4269    var jzsaDownloadStatusTimer = null;
4270
4271    function showDownloadStatus(message, isError) {
4272        if (!message || !document || !document.body) {
4273            return;
4274        }
4275
4276        var id = 'jzsa-download-status';
4277        var node = document.getElementById(id);
4278        if (!node) {
4279            node = document.createElement('div');
4280            node.id = id;
4281            node.className = 'jzsa-download-status';
4282            node.setAttribute('role', 'status');
4283            node.setAttribute('aria-live', 'polite');
4284            document.body.appendChild(node);
4285        }
4286
4287        node.textContent = message;
4288        node.classList.toggle('jzsa-download-status-error', !!isError);
4289        node.classList.add('jzsa-download-status-visible');
4290
4291        if (jzsaDownloadStatusTimer) {
4292            window.clearTimeout(jzsaDownloadStatusTimer);
4293        }
4294
4295        jzsaDownloadStatusTimer = window.setTimeout(function() {
4296            node.classList.remove('jzsa-download-status-visible');
4297        }, isError ? 4200 : 1800);
4298    }
4299
4300    function showDownloadErrorMessage(message) {
4301        var text = message || 'Download failed. Please try again.';
4302        if (window.console && typeof window.console.warn === 'function') {
4303            window.console.warn('Download failed:', text);
4304        }
4305        showDownloadStatus(text, true);
4306    }
4307
4308    function formatBytesForHumans(bytes) {
4309        var value = parseInt(bytes, 10);
4310        if (isNaN(value) || value <= 0) {
4311            return '';
4312        }
4313
4314        var units = ['B', 'KB', 'MB', 'GB', 'TB'];
4315        var unitIndex = 0;
4316        var sizedValue = value;
4317        while (sizedValue >= 1024 && unitIndex < units.length - 1) {
4318            sizedValue = sizedValue / 1024;
4319            unitIndex++;
4320        }
4321
4322        var decimals = (sizedValue >= 10 || unitIndex === 0) ? 0 : 1;
4323        return sizedValue.toFixed(decimals) + ' ' + units[unitIndex];
4324    }
4325
4326    function confirmLargeDownload(errorData) {
4327        if (typeof window.confirm !== 'function') {
4328            return false;
4329        }
4330
4331        var details = [];
4332        var actual = formatBytesForHumans(errorData.actualSizeBytes);
4333        var limit = formatBytesForHumans(errorData.maxSizeBytes);
4334        if (actual && limit) {
4335            details.push('File size: ' + actual + ' (configured limit: ' + limit + ').');
4336        } else if (actual) {
4337            details.push('File size: ' + actual + '.');
4338        }
4339        details.push('Do you want to continue downloading this file?');
4340
4341        var baseMessage = errorData.message || jzsaI18n('largeDownloadWarning');
4342        return window.confirm(baseMessage + '\n\n' + details.join('\n'));
4343    }
4344
4345    function downloadBlobToFile(blob, filename) {
4346        var url = window.URL.createObjectURL(blob);
4347        var link = document.createElement('a');
4348        link.href = url;
4349        link.download = filename;
4350        document.body.appendChild(link);
4351        link.click();
4352        document.body.removeChild(link);
4353        window.URL.revokeObjectURL(url);
4354    }
4355
4356    function detectDownloadErrorFromBlob(blob, callback) {
4357        if (typeof callback !== 'function') {
4358            return;
4359        }
4360
4361        if (!blob) {
4362            callback(null);
4363            return;
4364        }
4365
4366        var blobType = (blob.type || '').toLowerCase();
4367        var shouldInspectAsText =
4368            blobType.indexOf('application/json') !== -1 ||
4369            blobType.indexOf('text/') === 0 ||
4370            (!blobType && blob.size > 0 && blob.size <= 4096);
4371
4372        if (!shouldInspectAsText) {
4373            callback(null);
4374            return;
4375        }
4376
4377        function parseText(text) {
4378            if (!text) {
4379                callback(null);
4380                return;
4381            }
4382            try {
4383                var parsed = JSON.parse(text);
4384                if (parsed && parsed.success === false) {
4385                    callback(normalizeDownloadErrorData(parsed.data));
4386                    return;
4387                }
4388            } catch (_ignore) {
4389                // Not JSON payload from wp_send_json_error.
4390            }
4391            callback(null);
4392        }
4393
4394        if (typeof blob.text === 'function') {
4395            blob.text().then(parseText).catch(function() {
4396                callback(null);
4397            });
4398            return;
4399        }
4400
4401        if (typeof FileReader !== 'undefined') {
4402            var reader = new FileReader();
4403            reader.onload = function() {
4404                parseText(String(reader.result || ''));
4405            };
4406            reader.onerror = function() {
4407                callback(null);
4408            };
4409            reader.readAsText(blob);
4410            return;
4411        }
4412
4413        callback(null);
4414    }
4415
4416    // Helper: Setup download button
4417    function setupDownloadButton(swiper, $container) {
4418        var $downloadBtn = $container.find('.swiper-button-download');
4419        if ($downloadBtn.length === 0) {
4420            return; // Download button not enabled
4421        }
4422        var downloadWarningSizeBytes = getDownloadWarningSizeBytes($container);
4423
4424        function inferMediaTypeFromUrl(url) {
4425            if (!url) {
4426                return 'photo';
4427            }
4428            var normalized = String(url).split('?')[0].toLowerCase();
4429            if (
4430                normalized.indexOf('video-downloads.googleusercontent.com') !== -1 ||
4431                /\.(mp4|mov|m4v|webm|3gp)$/i.test(normalized)
4432            ) {
4433                return 'video';
4434            }
4435            return 'photo';
4436        }
4437
4438        function resolveSlideDownloadData(slideEl) {
4439            if (!slideEl) {
4440                return null;
4441            }
4442            var $slide = $(slideEl);
4443            var mediaType = $slide.attr('data-media-type') === 'video' ? 'video' : 'photo';
4444            var mediaUrl = '';
4445
4446            if (mediaType === 'video') {
4447                var $video = $slide.find('video.jzsa-video-player').first();
4448                mediaUrl = $video.prop('currentSrc') || $video.attr('src') || '';
4449            } else {
4450                var $img = $slide.find('img').first();
4451                mediaUrl = $img.attr('data-full-src') || $img.attr('src') || '';
4452            }
4453
4454            if (!mediaUrl) {
4455                return null;
4456            }
4457
4458            return {
4459                mediaUrl: mediaUrl,
4460                mediaType: mediaType
4461            };
4462        }
4463
4464        function getDownloadPhotoIndex(downloadIndex) {
4465            if (!isNaN(downloadIndex) && downloadIndex > 0) {
4466                return downloadIndex - 1;
4467            }
4468            return getSwiperPhotoIndex(swiper);
4469        }
4470
4471        function buildFilename(mediaType, downloadIndex) {
4472            var currentIndex = getDownloadPhotoIndex(downloadIndex);
4473            // Use original filename from Google Photos when available.
4474            var photo = getContainerPhoto($container, currentIndex);
4475            if (photo && photo.filename) {
4476                return photo.filename;
4477            }
4478            var safeIndex = currentIndex + 1;
4479            var prefix = mediaType === 'video' ? 'video-' : 'photo-';
4480            var extension = mediaType === 'video' ? '.mp4' : '.jpg';
4481            return prefix + safeIndex + extension;
4482        }
4483
4484        function fetchFilenameForDownload(mediaType, mediaUrl, downloadIndex, onDone) {
4485            var photoIndex = getDownloadPhotoIndex(downloadIndex);
4486            var photo = getContainerPhoto($container, photoIndex) || {};
4487            if (!photo.id || photo.filename || typeof jzsaAjax === 'undefined' || !jzsaAjax.photoMetaNonce) {
4488                onDone(buildFilename(mediaType, downloadIndex));
4489                return;
4490            }
4491
4492            var albumUrl = $container.attr('data-album-url') || '';
4493            var photoUrl = buildPhotoPageUrl(albumUrl, photo.id);
4494            if (!photoUrl) {
4495                onDone(buildFilename(mediaType, downloadIndex));
4496                return;
4497            }
4498
4499            $.ajax({
4500                url: jzsaAjax.ajaxUrl,
4501                type: 'POST',
4502                data: {
4503                    action: 'jzsa_fetch_photo_meta',
4504                    nonce: jzsaAjax.photoMetaNonce,
4505                    photo_url: photoUrl,
4506                    media_url: mediaUrl,
4507                    media_type: mediaType,
4508                    need_exif: 'false',
4509                    need_filename: 'true'
4510                }
4511            }).done(function(response) {
4512                if (response && response.success && response.data && response.data.filename) {
4513                    photo.filename = response.data.filename;
4514                    replaceContainerPhotoByGlobalIndex($container, photoIndex, photo);
4515                }
4516            }).always(function() {
4517                onDone(buildFilename(mediaType, downloadIndex));
4518            });
4519        }
4520
4521        $downloadBtn.on('click', function(e) {
4522            e.stopPropagation();
4523            var $clickedBtn = $(this);
4524            var mediaUrl = $clickedBtn.attr('data-download-url') || '';
4525            var mediaType = $clickedBtn.attr('data-download-type') || '';
4526            var downloadIndex = parseInt($clickedBtn.attr('data-download-index'), 10);
4527
4528            if (!mediaType) {
4529                mediaType = $clickedBtn.closest('.swiper-slide').attr('data-media-type') === 'video' ? 'video' : '';
4530            }
4531
4532            if (!mediaUrl) {
4533                // Fallback for container-level download button: resolve from active slide.
4534                var activeSlideData = resolveSlideDownloadData(swiper.slides[swiper.activeIndex]);
4535                if (activeSlideData) {
4536                    mediaUrl = activeSlideData.mediaUrl;
4537                    if (!mediaType) {
4538                        mediaType = activeSlideData.mediaType;
4539                    }
4540                }
4541            }
4542
4543            if (!mediaUrl) {
4544                return;
4545            }
4546
4547            if (!mediaType) {
4548                mediaType = inferMediaTypeFromUrl(mediaUrl);
4549            }
4550
4551            // Google Photos doesn't allow direct downloads due to CORS
4552            // We need to download via WordPress AJAX proxy
4553            var originalTitle = $clickedBtn.attr('title');
4554            var filename = '';
4555
4556            function restoreButtonState() {
4557                $clickedBtn.attr('title', originalTitle);
4558                $clickedBtn.css('opacity', '');
4559            }
4560
4561            function requestProxyDownload(allowLargeDownload) {
4562                $clickedBtn.attr('title', 'Downloading...');
4563                $clickedBtn.css('opacity', '0.5');
4564                showDownloadStatus(allowLargeDownload ? 'Preparing large download...' : 'Preparing download...', false);
4565
4566                var requestData = {
4567                    action: 'jzsa_download_image',
4568                    nonce: jzsaAjax.downloadNonce,
4569                    media_url: mediaUrl,
4570                    image_url: mediaUrl,
4571                    filename: filename
4572                };
4573                if (downloadWarningSizeBytes !== null) {
4574                    requestData.warning_size_bytes = downloadWarningSizeBytes;
4575                }
4576                if (allowLargeDownload) {
4577                    requestData.allow_large_download = 'true';
4578                }
4579
4580                // Use WordPress AJAX to proxy the download
4581                $.ajax({
4582                    url: jzsaAjax.ajaxUrl,
4583                    type: 'POST',
4584                    data: requestData,
4585                    xhrFields: {
4586                        responseType: 'blob'
4587                    },
4588                    success: function(blob) {
4589                        detectDownloadErrorFromBlob(blob, function(blobErrorData) {
4590                            if (blobErrorData) {
4591                                if (blobErrorData.requiresLargeDownloadConfirmation && !allowLargeDownload) {
4592                                    restoreButtonState();
4593                                    if (confirmLargeDownload(blobErrorData)) {
4594                                        requestProxyDownload(true);
4595                                    } else {
4596                                        showDownloadStatus('Download canceled.', false);
4597                                    }
4598                                    return;
4599                                }
4600
4601                                showDownloadErrorMessage(blobErrorData.message);
4602                                restoreButtonState();
4603                                return;
4604                            }
4605
4606                            downloadBlobToFile(blob, filename);
4607                            showDownloadStatus('Download started.', false);
4608                            restoreButtonState();
4609                        });
4610                    },
4611                    error: function(xhr, _status, error) {
4612                        function handleDownloadErrorData(errorData) {
4613                            if (errorData.requiresLargeDownloadConfirmation && !allowLargeDownload) {
4614                                restoreButtonState();
4615                                if (confirmLargeDownload(errorData)) {
4616                                    requestProxyDownload(true);
4617                                } else {
4618                                    showDownloadStatus('Download canceled.', false);
4619                                }
4620                                return;
4621                            }
4622
4623                            showDownloadErrorMessage(errorData.message);
4624                            restoreButtonState();
4625                        }
4626
4627                        function openDirectDownloadFallback() {
4628                            console.error('Download failed:', error);
4629
4630                            // Fallback: Try direct link with download attribute
4631                            var link = document.createElement('a');
4632                            link.href = mediaUrl;
4633                            link.download = filename;
4634                            link.target = '_blank';
4635                            link.rel = 'noopener noreferrer';
4636                            document.body.appendChild(link);
4637                            link.click();
4638                            document.body.removeChild(link);
4639                            showDownloadStatus('Opening direct download...', false);
4640                            restoreButtonState();
4641                        }
4642
4643                        var ajaxErrorData = getAjaxErrorData(xhr);
4644                        if (ajaxErrorData) {
4645                            handleDownloadErrorData(ajaxErrorData);
4646                            return;
4647                        }
4648
4649                        if (xhr && xhr.response && typeof xhr.response.text === 'function') {
4650                            detectDownloadErrorFromBlob(xhr.response, function(blobErrorData) {
4651                                if (blobErrorData) {
4652                                    handleDownloadErrorData(blobErrorData);
4653                                    return;
4654                                }
4655
4656                                openDirectDownloadFallback();
4657                            });
4658                            return;
4659                        }
4660
4661                        openDirectDownloadFallback();
4662                    }
4663                });
4664            }
4665
4666            fetchFilenameForDownload(mediaType, mediaUrl, downloadIndex, function(resolvedFilename) {
4667                filename = resolvedFilename;
4668                requestProxyDownload(false);
4669            });
4670        });
4671    }
4672
4673    // Helper: Setup slideshow progress bar
4674    function setupAutoplayProgress(swiper, $container) {
4675        var $progressBar = $container.find('.swiper-slideshow-progress-bar');
4676        var $progressContainer = $container.find('.swiper-slideshow-progress');
4677        var progressInterval = null;
4678        var progressCycleToken = 0;
4679
4680        // Hide progress bar initially if autoplay is not running
4681        if (!swiper.autoplay || !swiper.autoplay.running) {
4682            $progressContainer.css('display', 'none');
4683        }
4684
4685        function startProgress() {
4686            // Clear any existing interval
4687            if (progressInterval) {
4688                clearInterval(progressInterval);
4689            }
4690
4691            // Get current autoplay delay
4692            var delay = swiper.params.autoplay.delay;
4693
4694            if (!delay || delay <= 0) {
4695                return;
4696            }
4697
4698            // Show progress bar
4699            $progressContainer.css('display', 'block');
4700
4701            // Reset to 100%
4702            $progressBar.css({
4703                'transform': 'scaleX(1)',
4704                'transition': 'none'
4705            });
4706
4707            // Adjust animation duration so slide transition starts just as progress completes
4708            // Make progress bar last almost the entire delay, ending right when slide transitions
4709            var progressDuration = delay + 500;
4710            if (progressDuration < 0) progressDuration = delay;
4711            var cycleToken = ++progressCycleToken;
4712
4713            // Start animation on next paint instead of forcing synchronous reflow.
4714            runOnNextPaint(function() {
4715                if (cycleToken !== progressCycleToken || !$progressBar.length) {
4716                    return;
4717                }
4718                $progressBar.css({
4719                    'transform': 'scaleX(0)',
4720                    'transition': 'transform ' + progressDuration + 'ms linear'
4721                });
4722            });
4723        }
4724
4725        function stopProgress() {
4726            if (progressInterval) {
4727                clearInterval(progressInterval);
4728                progressInterval = null;
4729            }
4730            progressCycleToken++;
4731
4732            // Reset to full width and hide
4733            $progressBar.css({
4734                'transform': 'scaleX(1)',
4735                'transition': 'none'
4736            });
4737            $progressContainer.css('display', 'none');
4738        }
4739
4740        function holdProgressAtStart() {
4741            if (progressInterval) {
4742                clearInterval(progressInterval);
4743                progressInterval = null;
4744            }
4745            progressCycleToken++;
4746
4747            // Reset to full width and keep visible (used for hover-pause parity with gallery mode).
4748            $progressBar.css({
4749                'transform': 'scaleX(1)',
4750                'transition': 'none'
4751            });
4752            $progressContainer.css('display', 'block');
4753        }
4754
4755        function updateProgressVisibility() {
4756            // Only show in fullscreen when autoplay is running
4757            if (swiper.autoplay && swiper.autoplay.running) {
4758                startProgress();
4759            } else {
4760                stopProgress();
4761            }
4762        }
4763
4764        // Listen to autoplay events
4765        swiper.on('autoplayStart', startProgress);
4766        swiper.on('autoplayStop', stopProgress);
4767        swiper.on('autoplayPause', holdProgressAtStart);
4768        swiper.on('autoplayResume', startProgress);
4769        swiper.on('slideChange', function() {
4770            if (swiper.autoplay && swiper.autoplay.running) {
4771                startProgress();
4772            }
4773        });
4774
4775        // Start progress bar after a delay to ensure autoplay has started
4776        setTimeout(function() {
4777            if (swiper.autoplay && swiper.autoplay.running) {
4778                startProgress();
4779            }
4780        }, 500);
4781
4782        return {
4783            start: startProgress,
4784            stop: stopProgress,
4785            updateVisibility: updateProgressVisibility
4786        };
4787    }
4788
4789    // Helper: Setup play/pause button
4790    function setupPlayPauseButton(swiper, $container) {
4791        var $playPauseBtn = $container.find('.swiper-button-play-pause');
4792
4793        // Update button state based on autoplay status
4794        function updateButtonState() {
4795            if (swiper.autoplay && swiper.autoplay.running) {
4796                $playPauseBtn.addClass('playing');
4797            } else {
4798                $playPauseBtn.removeClass('playing');
4799            }
4800        }
4801
4802        // Toggle play/pause
4803        function togglePlayPause() {
4804			if (swiper.autoplay) {
4805				// Explicit user action - clear interrupted state
4806				clearCountdownRing($container);
4807				if (swiper.autoplay.running) {
4808					swiper.autoplay.stop();
4809					jzsaDebug('⏸️ Autoplay paused');
4810				} else {
4811					swiper.autoplay.start();
4812					jzsaDebug('▶️ Autoplay started');
4813				}
4814                updateButtonState();
4815            }
4816        }
4817
4818        // Click handler
4819        $playPauseBtn.on('click', function(e) {
4820            e.stopPropagation();
4821            togglePlayPause();
4822        });
4823
4824        // Listen to autoplay events to update button state
4825        swiper.on('autoplayStart', updateButtonState);
4826        swiper.on('autoplayStop', updateButtonState);
4827        swiper.on('autoplayPause', updateButtonState);
4828        swiper.on('autoplayResume', updateButtonState);
4829
4830        // Initialize button state
4831        updateButtonState();
4832
4833        // Return toggle function for keyboard binding
4834        return togglePlayPause;
4835    }
4836
4837    // Helper: Setup fullscreen switch handlers
4838    function setupFullscreenSwitchHandlers(swiper, $container, params) {
4839        if (params && params.interactionLock) {
4840            return;
4841        }
4842
4843        // When entering fullscreen from carousel mode, ensure we focus the
4844        // exact slide the user clicked in the preview (multi-slide) view.
4845        function focusClickedSlide(e) {
4846            if (params.mode !== 'carousel') {
4847                return;
4848            }
4849
4850            var $clickedSlide = $(e.target).closest('.swiper-slide');
4851            if (!$clickedSlide.length) {
4852                return;
4853            }
4854
4855            // In loop mode Swiper duplicates slides. Use the real slide index
4856            // stored in data-swiper-slide-index to avoid jumping to the
4857            // duplicated copy at the end of the wrapper.
4858            var realIndexAttr = $clickedSlide.attr('data-swiper-slide-index');
4859            var realIndex = realIndexAttr != null ? parseInt(realIndexAttr, 10) : NaN;
4860
4861            if (!isNaN(realIndex) && realIndex >= 0) {
4862                if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
4863                    swiper.slideToLoop(realIndex, 0, false);
4864                } else {
4865                    swiper.slideTo(realIndex, 0, false);
4866                }
4867            } else {
4868                // Fallback: use DOM index if real index is not available
4869                var clickedIndex = $clickedSlide.index();
4870                if (clickedIndex >= 0 && clickedIndex < swiper.slides.length) {
4871                    swiper.slideTo(clickedIndex, 0, false);
4872                }
4873            }
4874        }
4875
4876        // EXPAND GESTURE HANDLERS
4877        // Lightbox and fullscreen gestures are wired independently.
4878        // Lightbox handlers are bound first; stopImmediatePropagation prevents
4879        // the fullscreen handler from also firing when both use the same gesture.
4880        var lightboxGesture  = params.lightboxToggle  || 'disabled';
4881        var fullscreenGesture = params.fullscreenToggle || 'button-only';
4882		// A click and a double-click cannot be reliably distinguished on the same
4883		// gallery surface. Keep both viewers button-only as a defensive fallback
4884		// even if malformed data bypasses the server-side semantic validation.
4885		if ((lightboxGesture === 'click' || lightboxGesture === 'double-click') &&
4886			(fullscreenGesture === 'click' || fullscreenGesture === 'double-click')) {
4887			lightboxGesture = 'button-only';
4888			fullscreenGesture = 'button-only';
4889		}
4890
4891        // ── Lightbox gestures ────────────────────────────────────────────────
4892        if (lightboxGesture === 'click') {
4893            $container.on('click', function(e) {
4894                if (!shouldIgnoreClick(e.target)) {
4895                    e.preventDefault();
4896                    e.stopImmediatePropagation();
4897                    jzsaDebug('🔍 Single-click toggling lightbox');
4898                    toggleLightbox($container[0], params.showHintsOnLightbox);
4899                }
4900            });
4901        } else if (lightboxGesture === 'double-click') {
4902            $container.on('dblclick', function(e) {
4903                if (!shouldIgnoreClick(e.target)) {
4904                    e.preventDefault();
4905                    e.stopImmediatePropagation();
4906                    clearPendingNavClick();
4907                    jzsaDebug('🔍 Double-click toggling lightbox');
4908                    toggleLightbox($container[0], params.showHintsOnLightbox);
4909                }
4910            });
4911            $container.on('touchend', function(e) {
4912                if (!shouldIgnoreClick(e.target)) {
4913                    handleDoubleTap(e, function() {
4914                        clearPendingNavClick();
4915                        jzsaDebug('🔍 Double-tap toggling lightbox');
4916                        toggleLightbox($container[0], params.showHintsOnLightbox);
4917                    });
4918                }
4919            });
4920        }
4921
4922        // ── Fullscreen gestures (skip when lightbox is open) ─────────────────
4923        if (fullscreenGesture === 'click') {
4924            $container.on('click', function(e) {
4925                if (!shouldIgnoreClick(e.target) && !isLightboxActive($container[0])) {
4926                    e.preventDefault();
4927                    e.stopImmediatePropagation();
4928                    if (!isFullscreen()) {
4929                        focusClickedSlide(e);
4930                        jzsaDebug('🔍 Single-click entering fullscreen');
4931                        applyFullscreenAutoplaySettings(swiper, {
4932                            fullscreenSlideshow: params.fullscreenSlideshow,
4933                            fullscreenSlideshowDelay: params.fullscreenSlideshowDelay,
4934                            slideshowPausedByInteraction: params.slideshowPausedByInteraction
4935                        });
4936                    } else {
4937                        jzsaDebug('🔍 Single-click exiting fullscreen');
4938                    }
4939                    toggleFullscreen($container[0], params.showHintsOnFullscreen);
4940                }
4941            });
4942        } else if (fullscreenGesture === 'double-click') {
4943            $container.on('dblclick', function(e) {
4944                if (!shouldIgnoreClick(e.target) && !isLightboxActive($container[0])) {
4945                    e.preventDefault();
4946                    clearPendingNavClick();
4947                    if (!isFullscreen()) {
4948                        focusClickedSlide(e);
4949                        jzsaDebug('🔍 Double-click entering fullscreen');
4950                        applyFullscreenAutoplaySettings(swiper, {
4951                            fullscreenSlideshow: params.fullscreenSlideshow,
4952                            fullscreenSlideshowDelay: params.fullscreenSlideshowDelay,
4953                            slideshowPausedByInteraction: params.slideshowPausedByInteraction
4954                        });
4955                    } else {
4956                        jzsaDebug('🔍 Double-click exiting fullscreen');
4957                    }
4958                    toggleFullscreen($container[0], params.showHintsOnFullscreen);
4959                }
4960            });
4961            $container.on('touchend', function(e) {
4962                if (!shouldIgnoreClick(e.target) && !isLightboxActive($container[0])) {
4963                    handleDoubleTap(e, function(evt) {
4964                        clearPendingNavClick();
4965                        if (!isFullscreen()) {
4966                            focusClickedSlide(evt);
4967                            jzsaDebug('🔍 Double-tap entering fullscreen');
4968                            applyFullscreenAutoplaySettings(swiper, {
4969                                fullscreenSlideshow: params.fullscreenSlideshow,
4970                                fullscreenSlideshowDelay: params.fullscreenSlideshowDelay,
4971                                slideshowPausedByInteraction: params.slideshowPausedByInteraction
4972                            });
4973                        } else {
4974                            jzsaDebug('🔍 Double-tap exiting fullscreen');
4975                        }
4976                        toggleFullscreen($container[0], params.showHintsOnFullscreen);
4977                    });
4978                }
4979            });
4980        }
4981
4982		// TOUCH BUTTON REVEAL: show link/download on tap; ignore scroll/swipe gestures.
4983		// Passive listeners so iOS never has to wait for JS before committing a scroll frame.
4984		if (hasTouchInput()) {
4985			var touchRevealTimer = null;
4986			var sliderEl = $container[0];
4987			sliderEl.addEventListener('touchstart', function(e) {
4988				var t = e.touches[0];
4989				$container.data('jzsaRevealTouchX', t.clientX).data('jzsaRevealTouchY', t.clientY);
4990			}, { passive: true });
4991			sliderEl.addEventListener('touchend', function(e) {
4992				var startX = $container.data('jzsaRevealTouchX');
4993				var startY = $container.data('jzsaRevealTouchY');
4994				$container.removeData('jzsaRevealTouchX jzsaRevealTouchY');
4995				if (startX == null) return;
4996				var t = e.changedTouches[0];
4997				if (Math.abs(t.clientX - startX) > 8 || Math.abs(t.clientY - startY) > 8) return;
4998				$container.addClass('jzsa-touch-active');
4999				clearTimeout(touchRevealTimer);
5000				touchRevealTimer = setTimeout(function() {
5001					$container.removeClass('jzsa-touch-active');
5002				}, 3000);
5003			}, { passive: true });
5004			sliderEl.addEventListener('touchcancel', function() {
5005				$container.removeData('jzsaRevealTouchX jzsaRevealTouchY');
5006			}, { passive: true });
5007		}
5008
5009		// FULLSCREEN NAVIGATION CURSOR: left/right chevron cursors in fullscreen
5010		// hint at click-to-navigate (button-only and double-click modes only).
5011		// Uses a dynamic <style> element with a none→real two-frame kick to
5012		// force the browser to repaint the cursor (browsers skip repainting
5013		// when the cursor value is unchanged on a stationary mouse).
5014		var CURSOR_PREV = 'url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'22\' height=\'22\'%3E%3Cpath d=\'M14 5.6L8.4 11.2l5.6 5.6\' fill=\'none\' stroke=\'black\' stroke-width=\'2.8\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/%3E%3Cpath d=\'M14 5.6L8.4 11.2l5.6 5.6\' fill=\'none\' stroke=\'white\' stroke-width=\'1.4\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/%3E%3C/svg%3E") 11 11, w-resize';
5015		var CURSOR_NEXT = 'url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'22\' height=\'22\'%3E%3Cpath d=\'M8.4 5.6l5.6 5.6-5.6 5.6\' fill=\'none\' stroke=\'black\' stroke-width=\'2.8\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/%3E%3Cpath d=\'M8.4 5.6l5.6 5.6-5.6 5.6\' fill=\'none\' stroke=\'white\' stroke-width=\'1.4\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/%3E%3C/svg%3E") 11 11, e-resize';
5016		var navCursorActive = false;
5017		// When both lightbox and fullscreen gestures are set, lightbox takes priority
5018		// for the purpose of nav cursor and click-delay logic (matches original precedence).
5019		var activeExpandGesture = (lightboxGesture !== 'disabled') ? lightboxGesture : fullscreenGesture;
5020
5021		if (activeExpandGesture !== 'click' && activeExpandGesture !== 'disabled') {
5022			var lastMouseX = -1;
5023			var cursorStyleEl = document.createElement('style');
5024			$container[0].appendChild(cursorStyleEl);
5025			var containerId = $container.attr('id');
5026			var cursorKickTimer = null;
5027
5028			function applyNavCursor() {
5029				if (!isFullscreen() || lastMouseX < 0) {
5030					if (navCursorActive) {
5031						cursorStyleEl.textContent = '';
5032						navCursorActive = false;
5033					}
5034					return;
5035				}
5036				var rect = $container[0].getBoundingClientRect();
5037				var isLeft = (lastMouseX - rect.left) < rect.width / 2;
5038				var cursor = isLeft ? CURSOR_PREV : CURSOR_NEXT;
5039				// Two-frame kick: set 'none' first, then the real cursor on the
5040				// next animation frame to force a browser cursor repaint.
5041				// Exclude mosaic thumbnail slides so they keep cursor: pointer.
5042				var slideSelector = '#' + containerId + ' .swiper-slide:not(.jzsa-mosaic .swiper-slide):not(.jzsa-fullscreen-mosaic .swiper-slide)';
5043				cursorStyleEl.textContent =
5044					slideSelector + ' { cursor: none !important; }';
5045				if (cursorKickTimer) cancelAnimationFrame(cursorKickTimer);
5046				cursorKickTimer = requestAnimationFrame(function() {
5047					cursorStyleEl.textContent =
5048						slideSelector + ' { cursor: ' + cursor + ' !important; }';
5049				});
5050				navCursorActive = true;
5051			}
5052
5053			$container.on('mousemove', function(e) {
5054				lastMouseX = e.clientX;
5055				applyNavCursor();
5056			});
5057
5058			// Guard: re-apply every 500ms while in fullscreen to catch any
5059			// cursor resets caused by Swiper or browser re-layouts.
5060			var cursorGuardInterval = null;
5061			$(document).on('fullscreenchange webkitfullscreenchange', function() {
5062				if (isFullscreen()) {
5063					if (!cursorGuardInterval) {
5064						cursorGuardInterval = setInterval(applyNavCursor, 500);
5065					}
5066				} else {
5067					if (cursorGuardInterval) {
5068						clearInterval(cursorGuardInterval);
5069						cursorGuardInterval = null;
5070					}
5071				}
5072				setTimeout(applyNavCursor, 50);
5073			});
5074			swiper.on('slideChangeTransitionEnd', applyNavCursor);
5075		}
5076
5077		// FULLSCREEN NAVIGATION: single click navigates in fullscreen (all modes).
5078		// When fullscreenToggle is double-click, delay navigation so a double-click
5079		// to exit fullscreen does not trigger a spurious navigate first.
5080		var navClickTimer = null;
5081		var NAV_CLICK_DELAY = (lightboxGesture === 'double-click' || fullscreenGesture === 'double-click') ? 250 : 0;
5082
5083		function clearPendingNavClick() {
5084			if (navClickTimer) {
5085				clearTimeout(navClickTimer);
5086				navClickTimer = null;
5087			}
5088		}
5089
5090		if (NAV_CLICK_DELAY > 0) {
5091			$container.on('dblclick.jzsaNavGuard', function() {
5092				clearPendingNavClick();
5093			});
5094		}
5095
5096		$container.on('click', function(e) {
5097				if (!shouldIgnoreClick(e.target) && isFullscreen()) {
5098					// Single-click mode uses click to exit the viewer, not navigate.
5099					if (activeExpandGesture === 'click') {
5100						return;
5101					}
5102
5103					// Video slides: clickToPlay is disabled, so clicks on the
5104					// video area are free for navigation (plyr controls are
5105					// already filtered by shouldIgnoreClick above).
5106
5107					e.preventDefault();
5108					var clickX = e.originalEvent ? e.originalEvent.clientX : e.clientX;
5109					var containerRect = $container[0].getBoundingClientRect();
5110					var relativeX = clickX - containerRect.left;
5111					var direction = relativeX < containerRect.width / 2 ? 'prev' : 'next';
5112
5113				if (NAV_CLICK_DELAY > 0) {
5114					// Keep only one pending navigation click timer. Without this,
5115					// two click events in a double-click could leave one uncanceled.
5116					clearPendingNavClick();
5117					navClickTimer = setTimeout(function() {
5118						navClickTimer = null;
5119						pauseAutoplayOnInteraction(swiper, params);
5120						jzsaDebug('🔍 Fullscreen click: navigate ' + direction);
5121						if (direction === 'prev') {
5122							swiper.slidePrev(300);
5123						} else {
5124							swiper.slideNext(300);
5125						}
5126					}, NAV_CLICK_DELAY);
5127				} else {
5128					pauseAutoplayOnInteraction(swiper, params);
5129					jzsaDebug('🔍 Fullscreen click: navigate ' + direction);
5130					if (direction === 'prev') {
5131						swiper.slidePrev(300);
5132					} else {
5133						swiper.slideNext(300);
5134					}
5135				}
5136			}
5137		});
5138    }
5139
5140    // Helper: Setup video playback handling for Swiper
5141    function setupVideoHandling(swiper, $container, fullscreenChangeParams) {
5142        var videoAutoplayPaused = false;
5143
5144        // Capture-phase listener: on video slides, only block clicks that land
5145        // directly on the <video> element (to protect native controls). Clicks on
5146        // the surrounding wrapper area pass through to normal navigation handlers.
5147        $container[0].addEventListener('click', function(e) {
5148            var $activeSlide = $(swiper.slides[swiper.activeIndex]);
5149            if ($activeSlide.attr('data-media-type') !== 'video') {
5150                return;
5151            }
5152
5153            // Let clicks on Swiper UI controls and Plyr controls pass through
5154            if (shouldIgnoreClick(e.target)) {
5155                return;
5156            }
5157
5158            // Only block clicks on the actual video element or Plyr's video wrapper.
5159            // In fullscreen, let clicks through so they reach the navigation handler.
5160            if (e.target.tagName === 'VIDEO' || $(e.target).closest('.plyr__video-wrapper').length > 0) {
5161                if (!isFullscreen()) {
5162                    e.stopPropagation();
5163                    e.stopImmediatePropagation();
5164                }
5165            }
5166
5167        }, true);
5168
5169
5170        // Pause all videos in the gallery except the one in the active slide
5171        function pauseAllVideos(exceptSlide) {
5172            $container.find('.jzsa-video-player').each(function() {
5173                var videoEl = this;
5174                if (exceptSlide && $(videoEl).closest('.swiper-slide')[0] === exceptSlide) {
5175                    return;
5176                }
5177                if (!videoEl.paused) {
5178                    videoEl.pause();
5179                }
5180            });
5181        }
5182
5183        // When a video starts playing, pause slider autoplay (no inactivity timer -
5184        // autoplay stays paused for the entire duration of playback)
5185        // Use capture phase because media events don't bubble
5186        $container[0].addEventListener('play', function() {
5187            if (swiper.autoplay && swiper.autoplay.running) {
5188                videoAutoplayPaused = true;
5189                swiper.autoplay.stop();
5190                // Clear any existing inactivity timer so it doesn't fire mid-video
5191                if (fullscreenChangeParams.inactivityTimer) {
5192                    clearTimeout(fullscreenChangeParams.inactivityTimer);
5193                }
5194                jzsaDebug('⏸️ Autoplay paused for video playback');
5195            }
5196        }, true);
5197
5198        // When a video ends or is paused, start the inactivity countdown
5199        // to resume autoplay after the configured timeout (default 30s)
5200        function startAutoplayCountdown() {
5201            if (!videoAutoplayPaused) return;
5202            videoAutoplayPaused = false;
5203
5204            // Clear any existing inactivity timer
5205            if (fullscreenChangeParams.inactivityTimer) {
5206                clearTimeout(fullscreenChangeParams.inactivityTimer);
5207            }
5208
5209            fullscreenChangeParams.slideshowPausedByInteraction = true;
5210            if (fullscreenChangeParams.slideshowAutoresume !== 'disabled') {
5211                var timeoutMs = (fullscreenChangeParams.slideshowAutoresume || 30) * 1000;
5212                showCountdownRing($container, fullscreenChangeParams.slideshowAutoresume || 30);
5213                fullscreenChangeParams.inactivityTimer = setTimeout(function() {
5214                    if (fullscreenChangeParams.slideshowPausedByInteraction && swiper.autoplay && !swiper.autoplay.running) {
5215                        fullscreenChangeParams.slideshowPausedByInteraction = false;
5216                        clearCountdownRing($container);
5217                        swiper.autoplay.start();
5218                        jzsaDebug('▶️ Autoplay resumed after video inactivity timeout');
5219                    }
5220                }, timeoutMs);
5221                jzsaDebug('⏱️ Autoplay autoresume countdown started (' + (fullscreenChangeParams.slideshowAutoresume || 30) + 's)');
5222            }
5223        }
5224        $container[0].addEventListener('ended', startAutoplayCountdown, true);
5225        $container[0].addEventListener('pause', startAutoplayCountdown, true);
5226
5227        // Toggle class on container so CSS can disable nav overlays on video slides
5228        function updateVideoActiveClass() {
5229            var $activeSlide = $(swiper.slides[swiper.activeIndex]);
5230            $container.toggleClass('jzsa-video-active', $activeSlide.attr('data-media-type') === 'video');
5231        }
5232
5233        // Set initial state
5234        updateVideoActiveClass();
5235
5236        // On slide change: pause videos on previous slide, prep video on new slide
5237        swiper.on('slideChange', function() {
5238            updateVideoActiveClass();
5239            pauseAllVideos(null);
5240            videoAutoplayPaused = false;
5241
5242            // Pause and reset all video slides
5243            $container.find('.jzsa-slide-video').each(function() {
5244                var $video = $(this).find('.jzsa-video-player');
5245                var videoEl = $video[0];
5246                if (videoEl && videoEl.src) {
5247                    videoEl.pause();
5248                    videoEl.currentTime = 0;
5249                }
5250            });
5251        });
5252
5253    }
5254
5255    // Helper: Setup progressive image loading
5256    function setupProgressiveImageLoading(swiper, $container) {
5257        var fullImageRegistry = {};
5258        var fullscreenGateToken = 0;
5259        var hasQueuedFullscreenBulkPreload = false;
5260
5261        function setFullscreenQualityGate(active) {
5262            if (!$container || !$container.length) {
5263                return;
5264            }
5265
5266            if (active) {
5267                $container.addClass('jzsa-fullscreen-waiting');
5268                activateLoaderState($container, 'Loading full-resolution photo...', 'Still loading full-resolution photo...');
5269            } else {
5270                resetLoaderState($container);
5271                $container.removeClass('jzsa-fullscreen-waiting');
5272            }
5273        }
5274
5275        function ensureFullImageCached(fullSrc, onLoad, onError) {
5276            if (!fullSrc) {
5277                return;
5278            }
5279
5280            var entry = fullImageRegistry[fullSrc];
5281            if (entry && entry.state === 'loaded') {
5282                if (typeof onLoad === 'function') {
5283                    onLoad();
5284                }
5285                return;
5286            }
5287
5288            if (entry && entry.state === 'error') {
5289                if (typeof onError === 'function') {
5290                    onError();
5291                }
5292                return;
5293            }
5294
5295            if (entry && entry.state === 'loading') {
5296                if (typeof onLoad === 'function') {
5297                    entry.onLoad.push(onLoad);
5298                }
5299                if (typeof onError === 'function') {
5300                    entry.onError.push(onError);
5301                }
5302                return;
5303            }
5304
5305            entry = {
5306                state: 'loading',
5307                onLoad: [],
5308                onError: []
5309            };
5310            if (typeof onLoad === 'function') {
5311                entry.onLoad.push(onLoad);
5312            }
5313            if (typeof onError === 'function') {
5314                entry.onError.push(onError);
5315            }
5316            fullImageRegistry[fullSrc] = entry;
5317
5318            var tempImg = new Image();
5319            tempImg.referrerPolicy = 'no-referrer';
5320            tempImg.onload = function() {
5321                entry.state = 'loaded';
5322                entry.onLoad.forEach(function(cb) {
5323                    cb();
5324                });
5325                entry.onLoad = [];
5326                entry.onError = [];
5327            };
5328            tempImg.onerror = function() {
5329                entry.state = 'error';
5330                entry.onError.forEach(function(cb) {
5331                    cb();
5332                });
5333                entry.onLoad = [];
5334                entry.onError = [];
5335            };
5336            tempImg.src = fullSrc;
5337        }
5338
5339        function markImageAsFullLoaded($img, fullSrc) {
5340            if (!$img || !$img.length || !fullSrc) {
5341                return;
5342            }
5343            if ($img.attr('src') !== fullSrc) {
5344                $img.attr('src', fullSrc);
5345            }
5346            $img.data('full-loaded', true);
5347            $img.data('full-prefetched', true);
5348            $img.addClass('jzsa-full-loaded');
5349            $img.removeClass('jzsa-image-error');
5350        }
5351
5352        function preloadFullImage($img) {
5353            if (!$img || !$img.length) {
5354                return;
5355            }
5356
5357            var fullSrc = $img.attr('data-full-src');
5358            if (!fullSrc || $img.data('full-prefetched') || $img.data('full-loaded') === true) {
5359                return;
5360            }
5361
5362            ensureFullImageCached(fullSrc, function() {
5363                if (!$img.closest('html').length) {
5364                    return;
5365                }
5366                $img.data('full-prefetched', true);
5367            });
5368        }
5369
5370        // Swap to full source (never preview) once the full image is cached.
5371        function loadFullImage($img, onReady, onError) {
5372            if (!$img || !$img.length) {
5373                return;
5374            }
5375
5376            var fullSrc = $img.attr('data-full-src');
5377            if (!fullSrc) {
5378                return;
5379            }
5380
5381            if ($img.data('full-loaded') === true && $img.attr('src') === fullSrc) {
5382                return;
5383            }
5384
5385            ensureFullImageCached(
5386                fullSrc,
5387                function() {
5388                    if (!$img.closest('html').length) {
5389                        return;
5390                    }
5391                    markImageAsFullLoaded($img, fullSrc);
5392                    if (typeof onReady === 'function') {
5393                        onReady($img, fullSrc);
5394                    }
5395                },
5396                function() {
5397                    if (!$img.closest('html').length) {
5398                        return;
5399                    }
5400                    $img.addClass('jzsa-image-error');
5401                    $img.data('full-loaded', 'error');
5402                    // console.warn('Failed to load image:', fullSrc);
5403                    if (typeof onError === 'function') {
5404                        onError($img, fullSrc);
5405                    }
5406                }
5407            );
5408        }
5409
5410        function getSlideImageAt(index) {
5411            if (!swiper.slides || !swiper.slides[index]) {
5412                return null;
5413            }
5414            var $img = $(swiper.slides[index]).find('.jzsa-progressive-image');
5415            return $img.length ? $img : null;
5416        }
5417
5418        function processOffsets(offsets, processor) {
5419            if (!swiper || !swiper.slides || !swiper.slides.length) {
5420                return;
5421            }
5422            var currentIndex = swiper.activeIndex;
5423            offsets.forEach(function(offset) {
5424                var targetIndex = currentIndex + offset;
5425                var $img = getSlideImageAt(targetIndex);
5426                if ($img) {
5427                    processor($img);
5428                }
5429            });
5430        }
5431
5432        function preloadAllSlidesFull() {
5433            if (!swiper || !swiper.slides || !swiper.slides.length) {
5434                return;
5435            }
5436
5437            $(swiper.slides).each(function() {
5438                var $img = $(this).find('.jzsa-progressive-image');
5439                preloadFullImage($img);
5440            });
5441        }
5442
5443        function isRenderedAsFull($img) {
5444            if (!$img || !$img.length) {
5445                return true;
5446            }
5447            var fullSrc = $img.attr('data-full-src');
5448            if (!fullSrc) {
5449                return true;
5450            }
5451            return $img.data('full-loaded') === true && $img.attr('src') === fullSrc;
5452        }
5453
5454        function runProgressiveLoadingCycle() {
5455            var inFullscreen = !$container || !$container.length || isFullscreen($container[0]);
5456
5457            if (inFullscreen) {
5458                var $currentImg = getSlideImageAt(swiper.activeIndex);
5459                var needsCurrentGate = !isRenderedAsFull($currentImg);
5460
5461                if (needsCurrentGate) {
5462                    fullscreenGateToken += 1;
5463                    var gateToken = fullscreenGateToken;
5464                    setFullscreenQualityGate(true);
5465                    loadFullImage(
5466                        $currentImg,
5467                        function() {
5468                            if (gateToken !== fullscreenGateToken) {
5469                                return;
5470                            }
5471                            setFullscreenQualityGate(false);
5472                        },
5473                        function() {
5474                            if (gateToken !== fullscreenGateToken) {
5475                                return;
5476                            }
5477                            setFullscreenQualityGate(false);
5478                        }
5479                    );
5480                } else {
5481                    setFullscreenQualityGate(false);
5482                }
5483
5484                // Fullscreen rule: keep visible and nearby queue in full-res.
5485                processOffsets([1, -1, 2, -2], loadFullImage);
5486
5487                // Aggressively preload the queue for smooth looping in fullscreen.
5488                processOffsets([3, -3, 4, -4], preloadFullImage);
5489                if (!hasQueuedFullscreenBulkPreload) {
5490                    hasQueuedFullscreenBulkPreload = true;
5491                    preloadAllSlidesFull();
5492                }
5493                return;
5494            }
5495
5496            fullscreenGateToken += 1;
5497            setFullscreenQualityGate(false);
5498
5499            // Outside fullscreen this is purely speculative: it never changes what is on screen
5500            // (the thumbnail keeps its preview src), it only warms the browser cache so the
5501            // quality gate does not appear if a viewer is opened later. The admin never opens
5502            // one, so PHP switches it off there -- see preloadFullImages in class-orchestrator.
5503            // Deliberately the only gated step: the branch above and the unconditional first
5504            // call below still run, because fullscreen entry depends on their gate bookkeeping.
5505            if (!jzsaShouldPreloadFullImages()) {
5506                return;
5507            }
5508
5509            // Outside fullscreen, keep previews on screen but warm the cache for likely next images.
5510            processOffsets([0, 1, -1, 2], preloadFullImage);
5511        }
5512
5513        runProgressiveLoadingCycle();
5514
5515        swiper.on('slideChangeTransitionStart', function() {
5516            runProgressiveLoadingCycle();
5517        });
5518        swiper.on('slideChange', function() {
5519            runProgressiveLoadingCycle();
5520        });
5521
5522        if ($container && $container.length) {
5523            var containerId = ($container.attr('id') || 'gallery').replace(/[^a-zA-Z0-9_-]/g, '');
5524            var progressiveNamespace = '.jzsaProgressive-' + containerId;
5525
5526            $(document).off(
5527                'fullscreenchange' + progressiveNamespace +
5528                ' webkitfullscreenchange' + progressiveNamespace +
5529                ' mozfullscreenchange' + progressiveNamespace +
5530                ' MSFullscreenChange' + progressiveNamespace
5531            );
5532            $(document).on(
5533                'fullscreenchange' + progressiveNamespace +
5534                ' webkitfullscreenchange' + progressiveNamespace +
5535                ' mozfullscreenchange' + progressiveNamespace +
5536                ' MSFullscreenChange' + progressiveNamespace,
5537                function() {
5538                    window.setTimeout(function() {
5539                        runProgressiveLoadingCycle();
5540                    }, 0);
5541                }
5542            );
5543
5544            // iPhone pseudo fullscreen fallback.
5545            $container.off('jzsa:fullscreen-state' + progressiveNamespace);
5546            $container.on('jzsa:fullscreen-state' + progressiveNamespace, function() {
5547                window.setTimeout(function() {
5548                    runProgressiveLoadingCycle();
5549                }, 0);
5550            });
5551        }
5552    }
5553
5554    // Helper: Build Swiper configuration object
5555    function buildSwiperConfig(params) {
5556        var interactionLock = !!params.interactionLock;
5557
5558        var config = {
5559            // Initial slide
5560            initialSlide: params.initialSlide,
5561            // Navigation
5562            navigation: {
5563                nextEl: '#' + params.galleryId + ' .swiper-button-next',
5564                prevEl: '#' + params.galleryId + ' .swiper-button-prev',
5565            },
5566
5567        // Pagination: driven by info-bottom format string.
5568        // Supports {item}, {items}, and all per-photo placeholders.
5569        pagination: (function() {
5570            var base = {
5571                el: '#' + params.galleryId + ' .swiper-pagination'
5572            };
5573
5574            base.type = 'custom';
5575            base.renderCustom = function(swiper, current, total) {
5576                var $swiperEl = $(swiper.el);
5577                var isFs = $swiperEl.hasClass('jzsa-is-fullscreen') || $swiperEl.hasClass('jzsa-pseudo-fullscreen');
5578                var format = isFs
5579                    ? (isLightboxPresentationActive(swiper.el)
5580                        ? ($swiperEl.attr('data-lightbox-info-bottom') || $swiperEl.attr('data-info-bottom') || '')
5581                        : ($swiperEl.attr('data-fullscreen-info-bottom') || $swiperEl.attr('data-info-bottom') || ''))
5582                    : ($swiperEl.attr('data-info-bottom') || '');
5583
5584                if (!format) {
5585                    $swiperEl.find('.swiper-pagination').hide();
5586                    return '';
5587                }
5588
5589                var albumTitle = $swiperEl.attr('data-album-title') || '';
5590                var photoIndex = getSwiperPhotoIndex(swiper);
5591                var photo = getContainerPhoto($swiperEl, photoIndex);
5592                var totalCount = getContainerTotalCount($swiperEl);
5593                var text = renderInfoFormat(format, photo,
5594                    $.extend(buildItemPlaceholders(params.mode, swiper, photoIndex + 1, totalCount), { albumTitle: albumTitle })
5595                );
5596
5597                $swiperEl.find('.swiper-pagination').toggle(text !== '');
5598                return text;
5599            };
5600
5601            return base;
5602        })(),
5603
5604            // Default: no zoom unless enabled per mode.
5605            zoom: false,
5606
5607            // Autoplay - enable module if any viewer mode slideshow is not disabled
5608            autoplay: (params.slideshow !== 'disabled' || params.fullscreenSlideshow !== 'disabled' || params.lightboxSlideshow !== 'disabled') ? {
5609                delay: params.slideshowDelay * MILLISECONDS_PER_SECOND,
5610                disableOnInteraction: false,
5611            } : false,
5612
5613            // Loop
5614            loop: params.loop,
5615            rewind: !!params.rewind,
5616
5617            // Performance
5618            lazy: {
5619                loadPrevNext: true,
5620                loadPrevNextAmount: params.LAZY_LOAD_PREV_NEXT_AMOUNT,
5621            },
5622
5623            // Speed
5624            speed: params.SWIPER_SPEED,
5625
5626            // Touch
5627            grabCursor: !interactionLock,
5628            allowTouchMove: !interactionLock,
5629            simulateTouch: !interactionLock,
5630            noSwiping: true,
5631            noSwipingSelector: '.plyr__controls, .plyr__control',
5632
5633            // Keyboard control is enabled for exactly one gallery by the
5634            // page-level ownership coordinator after Swiper initialization.
5635            keyboard: {
5636                enabled: false,
5637            },
5638
5639            // Mouse wheel
5640            mousewheel: false,
5641        };
5642
5643        // Mode-specific configuration
5644        if (params.mode === 'carousel') {
5645            // Carousel mode: show multiple slides at once
5646            config.slidesPerView = params.SLIDES_MOBILE;
5647            config.spaceBetween = params.SPACING_MOBILE;
5648            config.centeredSlides = false;
5649            config.effect = 'slide';
5650
5651            // Responsive breakpoints for carousel
5652            config.breakpoints = {
5653                [params.BREAKPOINT_MOBILE]: {
5654                    slidesPerView: params.SLIDES_MOBILE,
5655                    spaceBetween: params.SPACING_MOBILE
5656                },
5657                [params.BREAKPOINT_TABLET]: {
5658                    slidesPerView: params.SLIDES_TABLET,
5659                    spaceBetween: params.SPACING_TABLET
5660                },
5661                [params.BREAKPOINT_DESKTOP]: {
5662                    slidesPerView: params.SLIDES_DESKTOP,
5663                    spaceBetween: params.SPACING_DESKTOP
5664                }
5665            };
5666
5667            // Enforce minimum of 2 visible slides in carousel mode (when enough photos exist)
5668            if (config.slidesPerView < 2) {
5669                config.slidesPerView = 2;
5670            }
5671            [''+params.BREAKPOINT_MOBILE, ''+params.BREAKPOINT_TABLET, ''+params.BREAKPOINT_DESKTOP].forEach(function(bp) {
5672                if (config.breakpoints[bp] && config.breakpoints[bp].slidesPerView < 2) {
5673                    config.breakpoints[bp].slidesPerView = 2;
5674                }
5675            });
5676
5677            // Disable zoom in carousel mode (doesn't work well with multiple slides)
5678            config.zoom = false;
5679        } else {
5680            // Single mode: Single photo viewer with touch pinch zoom support.
5681            config.slidesPerView = 1;
5682            config.spaceBetween = 0;
5683            config.centeredSlides = true;
5684
5685            // Historically, iOS/WebKit (≤ 16) used fade instead of slide to avoid
5686            // transient black frames during transform-based slide transitions.
5687            // However, testing on iOS 15.7 (Safari and Chrome) showed no black frames,
5688            // suggesting the issue was either fixed in WebKit long ago or never reliably
5689            // reproducible. The fade fallback is therefore disabled below - retain it
5690            // here in case it needs to be re-enabled for a specific future device report.
5691            //
5692            // if (isOldIosWebkit()) {
5693            //     config.effect = 'fade';
5694            //     config.fadeEffect = { crossFade: true };
5695            // } else {
5696            //     config.effect = 'slide';
5697            // }
5698            config.effect = 'slide';
5699
5700            // Keep zoom touch-only and disable double-click/double-tap toggles.
5701            // This avoids conflicts with fullscreen switch/navigation gestures.
5702            if (hasTouchInput()) {
5703                config.zoom = {
5704                    maxRatio: params.ZOOM_MAX_RATIO,
5705                    minRatio: params.ZOOM_MIN_RATIO,
5706                    toggle: false
5707                };
5708            } else {
5709                config.zoom = false;
5710            }
5711        }
5712
5713        return config;
5714    }
5715
5716    // ============================================================================
5717    // SWIPER INITIALIZATION
5718    // ============================================================================
5719
5720    function initializeSwiper(container, mode) {
5721        // Swiper configuration constants
5722        var SWIPER_SPEED = 600; // ms - transition speed between slides
5723        var ZOOM_MAX_RATIO = 3; // Maximum zoom level
5724        var ZOOM_MIN_RATIO = 1; // Minimum zoom level (no zoom)
5725        var LAZY_LOAD_PREV_NEXT_AMOUNT = 2; // Number of slides to preload
5726        var DEFAULT_SLIDESHOW_DELAY_FALLBACK = 5; // seconds - fallback slideshow delay if not specified
5727
5728            // Carousel mode breakpoint constants
5729        var BREAKPOINT_MOBILE = 320;
5730        var BREAKPOINT_TABLET = 640;
5731        var BREAKPOINT_DESKTOP = 1024;
5732        var SLIDES_MOBILE = 2;
5733        var SLIDES_TABLET = 2;
5734        var SLIDES_DESKTOP = 3;
5735        var SPACING_MOBILE = 10;
5736        var SPACING_TABLET = 15;
5737        var SPACING_DESKTOP = 20;
5738
5739        var $container = $(container);
5740        ensureGalleryId($container);
5741        if (isGalleryInitialized($container)) {
5742            return swipers[$container.attr('id')] || null;
5743        }
5744        markGalleryInitialized($container);
5745        var galleryId = $container.attr('id');
5746        var progressiveSettings = getProgressiveSliderSettings($container);
5747        var progressiveSliderEnabled = mode === 'slider' && progressiveSettings.enabled;
5748
5749        // Parse configuration from data attributes
5750        var allPhotos = getContainerPhotos($container);
5751        var totalCount = getContainerTotalCount($container);
5752
5753        if (progressiveSliderEnabled && !allPhotos.length) {
5754            bootstrapProgressiveSlider($container, mode);
5755            return null;
5756        }
5757
5758        // Guard: Swiper (especially with loop:true) crashes on empty containers
5759        if (!allPhotos.length) {
5760            // console.warn('[JZSA] No photos for gallery "' + galleryId + '", skipping Swiper init.');
5761            return null;
5762        }
5763
5764        var inlineShowNavigationSetting = readBooleanDataAttr($container, 'data-show-navigation', true);
5765        var fullscreenShowNavigationSetting = readBooleanDataAttr($container, 'data-fullscreen-show-navigation', inlineShowNavigationSetting);
5766        var inlineControlsColorSetting = $container.attr('data-controls-color') || '';
5767        var fullscreenControlsColorSetting = $container.attr('data-fullscreen-controls-color') || inlineControlsColorSetting;
5768        var inlineVideoControlsColorSetting = $container.attr('data-video-controls-color') || '';
5769        var fullscreenVideoControlsColorSetting = $container.attr('data-fullscreen-video-controls-color') || inlineVideoControlsColorSetting;
5770        var inlineInfoFontSizeSetting = getInfoFontSizePx($container, 'data-info-font-size', 12);
5771        var fullscreenInfoFontSizeSetting = getInfoFontSizePx($container, 'data-fullscreen-info-font-size', inl
5771ineInfoFontSizeSetting);
5772        var inlineInfoFontFamilySetting = getInfoFontFamily($container, 'data-info-font-family', '');
5773        var fullscreenInfoFontFamilySetting = getInfoFontFamily($container, 'data-fullscreen-info-font-family', inlineInfoFontFamilySetting);
5774        var inlineInfoFontColorSetting = getInfoFontColor($container, 'data-info-font-color', '');
5775        var fullscreenInfoFontColorSetting = getInfoFontColor($container, 'data-fullscreen-info-font-color', inlineInfoFontColorSetting);
5776        var fullscreenDisplayMaxWidthSetting = readOptionalPositiveIntAttr($container, 'data-fullscreen-display-max-width');
5777        var fullscreenDisplayMaxHeightSetting = readOptionalPositiveIntAttr($container, 'data-fullscreen-display-max-height');
5778        var fullscreenCornerRadiusSetting = parseInt($container.attr('data-fullscreen-corner-radius'), 10);
5779        if (isNaN(fullscreenCornerRadiusSetting) || fullscreenCornerRadiusSetting < 0) {
5780            fullscreenCornerRadiusSetting = null;
5781        }
5782        var inlineVideoControlsAutohideSetting = readBooleanDataAttr($container, 'data-video-controls-autohide', false);
5783        var fullscreenVideoControlsAutohideSetting = readBooleanDataAttr(
5784            $container,
5785            'data-fullscreen-video-controls-autohide',
5786            inlineVideoControlsAutohideSetting
5787        );
5788        var inlineSlideshowAutoresumeSetting = parseSlideshowAutoresumeAttr($container.attr('data-slideshow-autoresume'), 30);
5789        var fullscreenSlideshowAutoresumeSetting = parseSlideshowAutoresumeAttr(
5790            $container.attr('data-fullscreen-slideshow-autoresume'),
5791            inlineSlideshowAutoresumeSetting
5792        );
5793        var mosaicOpacitySetting = parseFloat($container.attr('data-mosaic-opacity'));
5794        if (isNaN(mosaicOpacitySetting)) {
5795            mosaicOpacitySetting = 0.3;
5796        }
5797        var fullscreenMosaicOpacitySetting = parseFloat($container.attr('data-fullscreen-mosaic-opacity'));
5798        if (isNaN(fullscreenMosaicOpacitySetting)) {
5799            fullscreenMosaicOpacitySetting = 0.3;
5800        }
5801        var lightboxMosaicOpacitySetting = parseFloat($container.attr('data-lightbox-mosaic-opacity'));
5802        if (isNaN(lightboxMosaicOpacitySetting)) {
5803            lightboxMosaicOpacitySetting = 0.3;
5804        }
5805
5806        var config = {
5807            // Photo data
5808            allPhotos: allPhotos,
5809            totalCount: totalCount,
5810            progressiveSlider: progressiveSliderEnabled,
5811            progressiveChunkSize: progressiveSettings.chunkSize,
5812
5813            // Slideshow settings ('auto', 'manual', or 'disabled')
5814            slideshow: $container.attr('data-slideshow') || 'disabled',
5815            slideshowDelay: parseInt($container.attr('data-slideshow-delay')) || DEFAULT_SLIDESHOW_DELAY_FALLBACK,
5816            fullscreenSlideshow: $container.attr('data-fullscreen-slideshow') || 'disabled',
5817            fullscreenSlideshowDelay: parseInt($container.attr('data-fullscreen-slideshow-delay')) || DEFAULT_SLIDESHOW_DELAY_FALLBACK,
5818            lightboxSlideshow: $container.attr('data-lightbox-slideshow') || 'disabled',
5819            lightboxSlideshowDelay: parseInt($container.attr('data-lightbox-slideshow-delay')) || DEFAULT_SLIDESHOW_DELAY_FALLBACK,
5820            slideshowAutoresume: inlineSlideshowAutoresumeSetting,
5821            fullscreenSlideshowAutoresume: fullscreenSlideshowAutoresumeSetting,
5822
5823            // Display settings
5824            loop: !progressiveSliderEnabled && allPhotos.length >= 4, // Loop requires enough slides for Swiper to work properly
5825            rewind: false,
5826            interactionLock: $container.attr('data-interaction-lock') === 'true',
5827            fullscreenToggle:
5828                $container.attr('data-fullscreen-toggle') || 'button-only',
5829            lightboxToggle: $container.attr('data-lightbox-toggle') || 'disabled',
5830            fullscreenMosaic: $container.attr('data-fullscreen-mosaic') === 'true',
5831            lightboxMosaic: $container.attr('data-lightbox-mosaic') === 'true',
5832            startAt: $container.attr('data-start-at') || '1',
5833            showNavigation: inlineShowNavigationSetting,
5834            fullscreenShowNavigation: fullscreenShowNavigationSetting,
5835            controlsColor: inlineControlsColorSetting,
5836            fullscreenControlsColor: fullscreenControlsColorSetting,
5837            videoControlsColor: inlineVideoControlsColorSetting,
5838            fullscreenVideoControlsColor: fullscreenVideoControlsColorSetting,
5839            infoFontSize: inlineInfoFontSizeSetting,
5840            fullscreenInfoFontSize: fullscreenInfoFontSizeSetting,
5841            infoFontFamily: inlineInfoFontFamilySetting,
5842            fullscreenInfoFontFamily: fullscreenInfoFontFamilySetting,
5843            infoFontColor: inlineInfoFontColorSetting,
5844            fullscreenInfoFontColor: fullscreenInfoFontColorSetting,
5845            fullscreenDisplayMaxWidth: fullscreenDisplayMaxWidthSetting,
5846            fullscreenDisplayMaxHeight: fullscreenDisplayMaxHeightSetting,
5847            fullscreenCornerRadius: fullscreenCornerRadiusSetting,
5848            videoControlsAutohide: inlineVideoControlsAutohideSetting,
5849            fullscreenVideoControlsAutohide: fullscreenVideoControlsAutohideSetting,
5850            albumTitle: $container.attr('data-album-title') || '',
5851            initialSlide: 0,
5852            initialGlobalSlide: 0,
5853
5854            // Mosaic settings
5855            mosaic: $container.attr('data-mosaic') === 'true',
5856            mosaicPosition: $container.attr('data-mosaic-position') || 'right',
5857            mosaicCount: parseInt($container.attr('data-mosaic-count'), 10) || 0, // 0 = auto
5858            mosaicGap: parseInt($container.attr('data-mosaic-gap'), 10) || 8,
5859            mosaicOpacity: mosaicOpacitySetting,
5860            mosaicBackground: $container.attr('data-mosaic-background') || '',
5861            fullscreenMosaicPosition: $container.attr('data-fullscreen-mosaic-position') || 'bottom',
5862            fullscreenMosaicLayout: $container.attr('data-fullscreen-mosaic-layout') || 'outer',
5863            fullscreenMosaicCount: parseInt($container.attr('data-fullscreen-mosaic-count'), 10) || 0,
5864            fullscreenMosaicGap: parseInt($container.attr('data-fullscreen-mosaic-gap'), 10) || 8,
5865            fullscreenMosaicOpacity: fullscreenMosaicOpacitySetting,
5866            fullscreenMosaicBackground: $container.attr('data-fullscreen-mosaic-background') || '',
5867            fullscreenMosaicCornerRadius: readOptionalNonNegativeIntAttr($container, 'data-fullscreen-mosaic-corner-radius'),
5868            lightboxMosaicPosition: $container.attr('data-lightbox-mosaic-position') || 'bottom',
5869            lightboxMosaicLayout: $container.attr('data-lightbox-mosaic-layout') || 'outer',
5870            lightboxMosaicCount: parseInt($container.attr('data-lightbox-mosaic-count'), 10) || 0,
5871            lightboxMosaicGap: parseInt($container.attr('data-lightbox-mosaic-gap'), 10) || 8,
5872            lightboxMosaicOpacity: lightboxMosaicOpacitySetting,
5873            lightboxMosaicBackground: $container.attr('data-lightbox-mosaic-background') || '',
5874            lightboxMosaicCornerRadius: readOptionalNonNegativeIntAttr($container, 'data-lightbox-mosaic-corner-radius')
5875        };
5876
5877        // Safe default: show inline play/pause only when normal-mode slideshow is enabled.
5878        // Fullscreen controls are handled separately via .jzsa-is-fullscreen styling.
5879        $container.toggleClass('jzsa-inline-slideshow-controls', config.slideshow !== 'disabled');
5880
5881        // Calculate initial slide based on startAt setting
5882        var startAtRaw = (config.startAt || 'random').toString().toLowerCase();
5883        if (progressiveSliderEnabled) {
5884            var bootstrappedGlobalIndex = parseInt($container.data('jzsaProgressiveInitialGlobalIndex'), 10);
5885            if (!isNaN(bootstrappedGlobalIndex) && bootstrappedGlobalIndex >= 0 && bootstrappedGlobalIndex < totalCount) {
5886                config.initialGlobalSlide = bootstrappedGlobalIndex;
5887            } else if (startAtRaw === 'random') {
5888                config.initialGlobalSlide = Math.floor(Math.random() * totalCount);
5889            } else {
5890                var progressiveRequested = parseInt(startAtRaw, 10);
5891                if (isNaN(progressiveRequested) || progressiveRequested < 1 || progressiveRequested > totalCount) {
5892                    progressiveRequested = 1;
5893                }
5894                config.initialGlobalSlide = progressiveRequested - 1;
5895            }
5896            var progressiveLocalIndex = findPhotoLocalIndex(allPhotos, config.initialGlobalSlide);
5897            config.initialSlide = progressiveLocalIndex >= 0 ? progressiveLocalIndex : 0;
5898        } else {
5899            if (startAtRaw === 'random') {
5900                if (totalCount > 0) {
5901                    config.initialSlide = Math.floor(Math.random() * totalCount);
5902                }
5903            } else {
5904                var requested = parseInt(startAtRaw, 10);
5905                if (isNaN(requested) || requested < 1 || requested > totalCount) {
5906                    requested = 1; // Out of range or invalid -> start at 1
5907                }
5908                config.initialSlide = requested - 1;
5909            }
5910        }
5911
5912        // Extract config values for easier access
5913        var allPhotos = config.allPhotos;
5914        var totalCount = config.totalCount;
5915        var slideshow = config.slideshow;
5916        var slideshowDelay = config.slideshowDelay;
5917        var fullscreenSlideshow = config.fullscreenSlideshow;
5918        var fullscreenSlideshowDelay = config.fullscreenSlideshowDelay;
5919        var slideshowAutoresume = config.slideshowAutoresume;
5920        var fullscreenSlideshowAutoresume = config.fullscreenSlideshowAutoresume;
5921        var loop = config.loop;
5922        var rewind = config.rewind;
5923        var interactionLock = config.interactionLock;
5924        var fullscreenToggle = interactionLock ? 'disabled' : config.fullscreenToggle;
5925        var showLinkButton = readBooleanDataAttr($container, 'data-show-link-button', false);
5926        var showDownloadButton = readBooleanDataAttr($container, 'data-show-download-button', false);
5927        var albumUrl = $container.attr('data-album-url') || '';
5928        var showNavigation = config.showNavigation;
5929        var fullscreenShowNavigation = config.fullscreenShowNavigation;
5930        var controlsColor = config.controlsColor;
5931        var fullscreenControlsColor = config.fullscreenControlsColor;
5932        var videoControlsColor = config.videoControlsColor;
5933        var fullscreenVideoControlsColor = config.fullscreenVideoControlsColor;
5934        var videoControlsAutohide = config.videoControlsAutohide;
5935        var fullscreenVideoControlsAutohide = config.fullscreenVideoControlsAutohide;
5936        var albumTitle = config.albumTitle;
5937        var initialSlide = config.initialSlide;
5938        var progressiveSlider = config.progressiveSlider;
5939        var mosaic = config.mosaic;
5940        var mosaicPosition = config.mosaicPosition;
5941        var mosaicCount = config.mosaicCount;
5942        var mosaicOpacity = config.mosaicOpacity;
5943        var fullscreenMosaic = config.fullscreenMosaic;
5944        var lightboxMosaic = config.lightboxMosaic;
5945        var fullscreenMosaicPosition = config.fullscreenMosaicPosition;
5946
5947        // console.log('📸 Initializing Swiper for gallery:', galleryId);
5948            // console.log('  - Mode:', mode);
5949            // console.log('  - Total photos:', totalCount);
5950            // console.log('  - Initial photos loaded:', allPhotos.length);
5951            // console.log('  - startAt setting:', startAt, '=> initial slide index (0-based):', initialSlide, '/', totalCount);
5952
5953        // Debug: Log configuration values
5954        // console.log('🔍 Configuration debug:');
5955        // console.log('  - data-fullscreen-slideshow-delay attribute:', $container.attr('data-fullscreen-slideshow-delay'));
5956        // console.log('  - fullscreenSlideshowDelay parsed:', fullscreenSlideshowDelay);
5957        // console.log('  - fullscreenSlideshowDelay in ms:', fullscreenSlideshowDelay * MILLISECONDS_PER_SECOND);
5958
5959        // Two-phase single bootstrap caused visible re-render flicker on some pages.
5960        // Keep slider mode one-pass for stable rendering.
5961        var useDeferredSingleFirstPaint = false;
5962        var shouldUseLazyHints = mode === 'slider';
5963        var lightboxEnabled = !interactionLock && !!config.lightboxToggle && config.lightboxToggle !== 'disabled';
5964        var carouselFsEnabled = fullscreenToggle !== 'disabled';
5965        var showCarouselTileFullscreenButtons =
5966            mode === 'carousel' && !interactionLock && (carouselFsEnabled || lightboxEnabled);
5967        var showCarouselTileLinkButtons =
5968            mode === 'carousel' && !interactionLock && showLinkButton && !!albumUrl;
5969        var showCarouselTileDownloadButtons =
5970            mode === 'carousel' && !interactionLock && showDownloadButton;
5971        $container.toggleClass('jzsa-carousel-tile-fs-enabled', showCarouselTileFullscreenButtons);
5972        var zoneFormats = readInfoZoneFormats($container);
5973        $container.attr('data-has-active-bottom-center', (zoneFormats.inline['info-bottom'] || '') ? 'true' : 'false');
5974        var slidesRenderOptions = {
5975            mode: mode,
5976            showCarouselTileFullscreenButtons: showCarouselTileFullscreenButtons,
5977            carouselLightboxEnabled: lightboxEnabled,
5978            carouselFsEnabled: carouselFsEnabled,
5979            showCarouselTileLinkButtons: showCarouselTileLinkButtons,
5980            showCarouselTileDownloadButtons: showCarouselTileDownloadButtons,
5981            carouselAlbumUrl: albumUrl,
5982            carouselZoneFormats: zoneFormats,
5983            carouselTotalCount: totalCount,
5984            carouselAlbumTitle: albumTitle,
5985            lazyHints: shouldUseLazyHints,
5986            eagerIndex: initialSlide
5987        };
5988
5989        function renderSwiperBootstrapSlides() {
5990            if (useDeferredSingleFirstPaint) {
5991                var bootstrapPhoto = allPhotos[initialSlide] || allPhotos[0];
5992                $container.find('.swiper-wrapper').html(
5993                    buildSlidesHtml(bootstrapPhoto ? [bootstrapPhoto] : [], {
5994                        mode: mode,
5995                        showCarouselTileFullscreenButtons: showCarouselTileFullscreenButtons,
5996                        lazyHints: true,
5997                        eagerIndex: 0
5998                    })
5999                );
6000                return;
6001            }
6002
6003            $container.find('.swiper-wrapper').html(buildSlidesHtml(allPhotos, slidesRenderOptions));
6004        }
6005
6006        renderSwiperBootstrapSlides();
6007
6008        // --------------------------------------------------------------------
6009        // Mosaic thumbnail strip
6010        // --------------------------------------------------------------------
6011
6012        var mosaicSwiper = null;
6013        var mosaicPageSize = 1;
6014        if (mosaic) {
6015            var $mosaicContainer = $('#' + galleryId + '-mosaic');
6016            if ($mosaicContainer.length) {
6017                var $mosaicViewport = $mosaicContainer.children('.jzsa-mosaic-viewport');
6018                if (!$mosaicViewport.length) {
6019                    var $existingMosaicWrapper = $mosaicContainer.children('.swiper-wrapper').first();
6020                    $mosaicViewport = $('<div class="jzsa-mosaic-viewport swiper"></div>');
6021                    if ($existingMosaicWrapper.length) {
6022                        $existingMosaicWrapper.wrap($mosaicViewport);
6023                        $mosaicViewport = $mosaicContainer.children('.jzsa-mosaic-viewport').first();
6024                    } else {
6025                        $mosaicViewport.append('<div class="swiper-wrapper"></div>');
6026                        $mosaicContainer.append($mosaicViewport);
6027                    }
6028                }
6029                var isVerticalMosaic = (mosaicPosition === 'left' || mosaicPosition === 'right');
6030                if (isVerticalMosaic) {
6031                    $mosaicContainer.addClass('jzsa-mosaic-vertical');
6032                    $mosaicViewport.addClass('jzsa-mosaic-vertical');
6033                }
6034                // Build thumb slides
6035                var thumbSlidesHtml = '';
6036                allPhotos.forEach(function(photo) {
6037                    var thumbUrl = photo.thumb || photo.preview || photo.full;
6038                    thumbSlidesHtml += '<div class="swiper-slide">' +
6039                        '<span class="jzsa-mosaic-thumb-inner">' +
6040                        '<img src="' + thumbUrl + '" alt="Thumb" loading="lazy" referrerpolicy="no-referrer" />' +
6041                        '</span></div>';
6042                });
6043                $mosaicViewport.find('.swiper-wrapper').html(thumbSlidesHtml);
6044
6045                var mosaicGap = config.mosaicGap;
6046                var mosaicThumbCount = allPhotos.length;
6047                $mosaicContainer[0].style.setProperty('--jzsa-mosaic-opacity', mosaicOpacity);
6048                if (config.mosaicBackground) {
6049                    $mosaicContainer[0].style.setProperty('--jzsa-mosaic-background', config.mosaicBackground);
6050                }
6051                var MOSAIC_TARGET_THUMB_SIZE = 100; // px – ideal thumb size for auto-count
6052                var MOSAIC_ARROW_LANE = 36;
6053                var mosaicHasArrows = false;
6054
6055                function getMosaicAvailableLength(includeArrowLanes) {
6056                    var $wrapper = $mosaicContainer.parent();
6057                    var mobile = window.innerWidth <= 480;
6058                    var availableLength;
6059                    if (mobile) {
6060                        availableLength = $wrapper.width() || 400;
6061                    } else if (mosaicPosition === 'left' || mosaicPosition === 'right') {
6062                        var wrapperH = $wrapper.height();
6063                        var albumH = $container.height();
6064                        availableLength = (wrapperH > 0 ? wrapperH : albumH) || 300;
6065                    } else {
6066                        availableLength = $wrapper.width() || 400;
6067                    }
6068                    if (includeArrowLanes && !mobile) {
6069                        availableLength = Math.max(1, availableLength - (MOSAIC_ARROW_LANE * 2));
6070                    }
6071                    return availableLength;
6072                }
6073
6074                // Calculate how many thumbs fit in the available space.
6075                function computeAutoMosaicCount(includeArrowLanes) {
6076                    var availableLength = getMosaicAvailableLength(includeArrowLanes);
6077                    // How many thumbs of MOSAIC_TARGET_THUMB_SIZE fit?
6078                    var fitCount = Math.floor((availableLength + mosaicGap) / (MOSAIC_TARGET_THUMB_SIZE + mosaicGap));
6079                    return Math.max(1, fitCount);
6080                }
6081
6082                function getEffectiveMosaicCount() {
6083                    var mobile = window.innerWidth <= 480;
6084                    var explicitCount = mosaicCount > 0;
6085                    var rawFullCount = explicitCount ? mosaicCount : computeAutoMosaicCount(false);
6086                    var fullCount = clampMosaicVisibleCount(rawFullCount, mosaicThumbCount);
6087                    mosaicHasArrows = !mobile && mosaicThumbCount > fullCount;
6088                    var rawCount = explicitCount ? mosaicCount : computeAutoMosaicCount(mosaicHasArrows);
6089                    return clampMosaicVisibleCount(rawCount, mosaicThumbCount);
6090                }
6091
6092                function buildMosaicConfig(startSlide) {
6093                    var mobile = window.innerWidth <= 480;
6094                    var count = getEffectiveMosaicCount();
6095                    var cfg = {
6096                        spaceBetween: mosaicGap,
6097                        freeMode: false,
6098                        watchSlidesProgress: true,
6099                        slideToClickedSlide: true,
6100                        initialSlide: startSlide,
6101                        watchOverflow: true,
6102                        slidesPerView: count,
6103                        slidesPerGroup: count
6104                    };
6105
6106                    if (mobile) {
6107                        cfg.direction = 'horizontal';
6108                    } else if (mosaicPosition === 'left' || mosaicPosition === 'right') {
6109                        cfg.direction = 'vertical';
6110                    } else {
6111                        cfg.direction = 'horizontal';
6112                    }
6113
6114                    return cfg;
6115                }
6116
6117                function resizeMosaic() {
6118                    var mobile = window.innerWidth <= 480;
6119                    if (mobile) {
6120                        $mosaicContainer.css({ width: '', height: '' });
6121                        $mosaicContainer.removeClass('jzsa-mosaic-has-arrows');
6122                        $mosaicViewport.removeClass('jzsa-mosaic-has-arrows');
6123                        return;
6124                    }
6125                    var count = getEffectiveMosaicCount();
6126                    var availableLength = getMosaicAvailableLength(mosaicHasArrows);
6127                    var thumbSize = (availableLength - (mosaicGap * (count - 1))) / count;
6128                    thumbSize = Math.max(1, Math.floor(thumbSize));
6129                    $mosaicContainer.toggleClass('jzsa-mosaic-has-arrows', mosaicHasArrows);
6130                    $mosaicViewport.toggleClass('jzsa-mosaic-has-arrows', mosaicHasArrows);
6131                    $mosaicContainer[0].style.setProperty('--jzsa-mosaic-arrow-lane', MOSAIC_ARROW_LANE + 'px');
6132                    if (mosaicPosition === 'left' || mosaicPosition === 'right') {
6133                        $mosaicContainer.css({ width: thumbSize + 'px', height: '' });
6134                    } else {
6135                        $mosaicContainer.css({ width: '', height: thumbSize + 'px' });
6136                    }
6137                    mosaicPageSize = count;
6138                    if (mosaicSwiper && !mosaicSwiper.destroyed) {
6139                        mosaicSwiper.params.slidesPerView = count;
6140                        mosaicSwiper.params.slidesPerGroup = count;
6141                        mosaicSwiper.update();
6142                    }
6143                }
6144
6145                mosaicSwiper = new Swiper($mosaicViewport[0], buildMosaicConfig(initialSlide));
6146                resizeMosaic();
6147                mosaicPageSize = getEffectiveMosaicCount();
6148
6149                // Deferred layout pass to pick up correct dimensions after first paint.
6150                var raf = window.requestAnimationFrame || function(cb) { window.setTimeout(cb, 16); };
6151                raf(function() { raf(resizeMosaic); });
6152
6153                // Navigation arrows
6154                if (!$mosaicContainer.find('.jzsa-mosaic-arrow-prev').length) {
6155                    $mosaicContainer.append(
6156                        '<button type="button" class="jzsa-mosaic-arrow jzsa-mosaic-arrow-prev swiper-button-prev" aria-label="Previous page"></button>' +
6157                        '<button type="button" class="jzsa-mosaic-arrow jzsa-mosaic-arrow-next swiper-button-next" aria-label="Next page"></button>'
6158                    );
6159                    $mosaicContainer.on('click', '.jzsa-mosaic-arrow-prev', function(e) {
6160                        e.preventDefault();
6161                        if (mosaicSwiper && !mosaicSwiper.destroyed) { mosaicSwiper.slidePrev(); }
6162                    });
6163                    $mosaicContainer.on('click', '.jzsa-mosaic-arrow-next', function(e) {
6164                        e.preventDefault();
6165                        if (mosaicSwiper && !mosaicSwiper.destroyed) { mosaicSwiper.slideNext(); }
6166                    });
6167                }
6168
6169                // Resize observer for dynamic layout
6170                if (typeof ResizeObserver !== 'undefined') {
6171                    var wrapperEl = $mosaicContainer.parent()[0];
6172                    if (wrapperEl) {
6173                        var mosaicResizeObserver = new ResizeObserver(function() { resizeMosaic(); });
6174                        mosaicResizeObserver.observe(wrapperEl);
6175                    }
6176                }
6177
6178                // Window resize: rebuild mosaic direction if orientation changes
6179                $(window).on('resize.jzsaMosaic-' + galleryId, function() {
6180                    if (!mosaicSwiper || mosaicSwiper.destroyed) { return; }
6181                    resizeMosaic();
6182                    var newCfg = buildMosaicConfig(0);
6183                    if (mosaicSwiper.params.direction !== newCfg.direction) {
6184                        var currentSlide = mosaicSwiper.activeIndex;
6185                        mosaicSwiper.destroy(true, true);
6186                        mosaicSwiper = new Swiper($mosaicViewport[0], buildMosaicConfig(currentSlide));
6187                    }
6188                });
6189            }
6190        }
6191
6192        function buildMosaicThumbSlidesHtml(photos) {
6193            var html = '';
6194            photos.forEach(function(photo, index) {
6195                var thumbUrl = photo.thumb || photo.preview || photo.full;
6196                var alt = photo.type === 'video' ? 'Video thumbnail ' : 'Photo thumbnail ';
6197                html += '<div class="swiper-slide">' +
6198                    '<span class="jzsa-mosaic-thumb-inner">' +
6199                    '<img src="' + thumbUrl + '" alt="' + alt + (index + 1) + '" loading="lazy" referrerpolicy="no-referrer" />' +
6200                    '</span></div>';
6201            });
6202            return html;
6203        }
6204
6205        function setupFullscreenMosaic(swiper) {
6206            if ((!fullscreenMosaic && !lightboxMosaic) || !swiper || !allPhotos.length) {
6207                return null;
6208            }
6209
6210            var fullscreenMosaicId = galleryId + '-fullscreen-mosaic';
6211            var $fullscreenMosaic = $('#' + fullscreenMosaicId);
6212            if (!$fullscreenMosaic.length) {
6213                $fullscreenMosaic = $(
6214                    '<div class="jzsa-fullscreen-mosaic jzsa-mosaic swiper" id="' + fullscreenMosaicId + '" aria-hidden="true">' +
6215                        '<div class="swiper-wrapper"></div>' +
6216                    '</div>'
6217                );
6218                $container.append($fullscreenMosaic);
6219            }
6220
6221            var fullscreenMosaicThumbCount = allPhotos.length;
6222            $fullscreenMosaic.find('.swiper-wrapper').html(buildMosaicThumbSlidesHtml(allPhotos));
6223            var FULLSCREEN_MOSAIC_TARGET_THUMB_SIZE = 86;
6224            var fullscreenMosaicEffectiveCount = 1;
6225            var fullscreenMosaicThumbSize = 1;
6226            var fullscreenMosaicPageStart = 0;
6227            var fullscreenMosaicVertical = false;
6228            var viewerMosaicEnabled = false;
6229            var FULLSCREEN_MOSAIC_ARROW_LANE = 42;
6230
6231            if (!$fullscreenMosaic.find('.jzsa-mosaic-arrow-prev').length) {
6232                $fullscreenMosaic.append(
6233                    '<button type="button" class="jzsa-mosaic-arrow jzsa-mosaic-arrow-prev swiper-button-prev" aria-label="Previous thumbnails"></button>' +
6234                    '<button type="button" class="jzsa-mosaic-arrow jzsa-mosaic-arrow-next swiper-button-next" aria-label="Next thumbnails"></button>'
6235                );
6236            }
6237
6238            function isFullscreenMosaicVertical() {
6239                return window.innerWidth > 480 && (fullscreenMosaicPosition === 'left' || fullscreenMosaicPosition === 'right');
6240            }
6241
6242            function getFullscreenMosaicRailGeometry() {
6243                var vertical = isFullscreenMosaicVertical();
6244                var containerRect = $container[0].getBoundingClientRect();
6245                var containerLength = vertical ? containerRect.height : containerRect.width;
6246                if (!containerLength || containerLength <= 0) {
6247                    containerLength = vertical ? (window.innerHeight || 600) : (window.innerWidth || 800);
6248                }
6249                var mosaicEl = $fullscreenMosaic[0];
6250                var originalStyle = {
6251                    left: mosaicEl.style.left,
6252                    right: mosaicEl.style.right,
6253                    top: mosaicEl.style.top,
6254                    bottom: mosaicEl.style.bottom,
6255                    width: mosaicEl.style.width,
6256                    height: mosaicEl.style.height
6257                };
6258                mosaicEl.style.left = '';
6259                mosaicEl.style.right = '';
6260                mosaicEl.style.top = '';
6261                mosaicEl.style.bottom = '';
6262                mosaicEl.style.width = '';
6263                mosaicEl.style.height = '';
6264                var startOffset = 0;
6265                var endOffset = 0;
6266                var computedStyle = window.getComputedStyle ? window.getComputedStyle($fullscreenMosaic[0]) : null;
6267                if (computedStyle) {
6268                    startOffset = parseFloat(vertical ? computedStyle.top : computedStyle.left);
6269                    endOffset = parseFloat(vertical ? computedStyle.bottom : computedStyle.right);
6270                    startOffset = isNaN(startOffset) ? 0 : startOffset;
6271                    endOffset = isNaN(endOffset) ? 0 : endOffset;
6272                }
6273                var rect = $fullscreenMosaic[0].getBoundingClientRect();
6274                var length = vertical ? rect.height : rect.width;
6275                mosaicEl.style.left = originalStyle.left;
6276                mosaicEl.style.right = originalStyle.right;
6277                mosaicEl.style.top = originalStyle.top;
6278                mosaicEl.style.bottom = originalStyle.bottom;
6279                mosaicEl.style.width = originalStyle.width;
6280                mosaicEl.style.height = originalStyle.height;
6281                var measuredLength = containerLength - startOffset - endOffset;
6282                if (measuredLength > 0) {
6283                    length = measuredLength;
6284                }
6285                if (length > 0) {
6286                    return {
6287                        vertical: vertical,
6288                        containerLength: containerLength,
6289                        startOffset: startOffset,
6290                        endOffset: endOffset,
6291                        availableLength: length
6292                    };
6293                }
6294                return {
6295                    vertical: vertical,
6296                    containerLength: containerLength,
6297                    startOffset: 0,
6298                    endOffset: 0,
6299                    availableLength: containerLength
6300                };
6301            }
6302
6303            function computeAutoFullscreenMosaicCount() {
6304                var availableLength = Math.max(1, getFullscreenMosaicRailGeometry().availableLength - (FULLSCREEN_MOSAIC_ARROW_LANE * 2));
6305                var fitCount = Math.floor((availableLength + config.fullscreenMosaicGap) / (FULLSCREEN_MOSAIC_TARGET_THUMB_SIZE + config.fullscreenMosaicGap));
6306                return Math.max(1, fitCount);
6307            }
6308
6309            function getEffectiveFullscreenMosaicCount() {
6310                var rawCount = config.fullscreenMosaicCount > 0 ? config.fullscreenMosaicCount : computeAutoFullscreenMosaicCount();
6311                return clampMosaicVisibleCount(rawCount, fullscreenMosaicThumbCount);
6312            }
6313
6314            function applyFullscreenMosaicSlideSize(thumbSize, vertical) {
6315                $fullscreenMosaic.find('.swiper-slide').each(function(index) {
6316                    this.style.setProperty('flex-basis', thumbSize + 'px', 'important');
6317                    this.style.setProperty('flex-shrink', '0', 'important');
6318                    if (vertical) {
6319                        this.style.removeProperty('width');
6320                        this.style.setProperty('height', thumbSize + 'px', 'important');
6321                        this.style.marginRight = '0px';
6322                        this.style.marginBottom = index < fullscreenMosaicThumbCount - 1 ? config.fullscreenMosaicGap + 'px' : '0px';
6323                    } else {
6324                        this.style.setProperty('width', thumbSize + 'px', 'important');
6325                        this.style.removeProperty('height');
6326                        this.style.marginRight = index < fullscreenMosaicThumbCount - 1 ? config.fullscreenMosaicGap + 'px' : '0px';
6327                        this.style.marginBottom = '0px';
6328                    }
6329                });
6330            }
6331
6332            function positionFullscreenMosaicPage(pageStart) {
6333                var maxStart = Math.max(0, fullscreenMosaicThumbCount - fullscreenMosaicEffectiveCount);
6334                fullscreenMosaicPageStart = Math.max(0, Math.min(pageStart, maxStart));
6335                var offset = fullscreenMosaicPageStart * (fullscreenMosaicThumbSize + config.fullscreenMosaicGap);
6336                var transform = fullscreenMosaicVertical ?
6337                    'translate3d(0px, -' + offset + 'px, 0px)' :
6338                    'translate3d(-' + offset + 'px, 0px, 0px)';
6339                $fullscreenMosaic.find('.swiper-wrapper').css({
6340                    flexDirection: fullscreenMosaicVertical ? 'column' : 'row',
6341                    transform: transform,
6342                    transitionDuration: '0ms'
6343                });
6344            }
6345
6346            function resizeFullscreenMosaic() {
6347                var rail = getFullscreenMosaicRailGeometry();
6348                var vertical = rail.vertical;
6349                var count = getEffectiveFullscreenMosaicCount();
6350                var availableLength = Math.max(1, rail.availableLength - (FULLSCREEN_MOSAIC_ARROW_LANE * 2));
6351                var thumbSize = (availableLength - (config.fullscreenMosaicGap * (count - 1))) / count;
6352                thumbSize = Math.max(1, Math.floor(thumbSize));
6353                var renderedLength = (thumbSize * count) + (config.fullscreenMosaicGap * (count - 1));
6354                var railLength = renderedLength + (FULLSCREEN_MOSAIC_ARROW_LANE * 2);
6355                var centeredOffset = Math.max(0, Math.floor((rail.availableLength - railLength) / 2));
6356                fullscreenMosaicVertical = vertical;
6357                fullscreenMosaicEffectiveCount = count;
6358                fullscreenMosaicThumbSize = thumbSize;
6359                $fullscreenMosaic.toggleClass('jzsa-mosaic-vertical', vertical);
6360                $fullscreenMosaic[0].style.setProperty('--jzsa-fullscreen-mosaic-arrow-lane', FULLSCREEN_MOSAIC_ARROW_LANE + 'px');
6361                $fullscreenMosaic[0].style.setProperty('--jzsa-fullscreen-mosaic-thumb-size', thumbSize + 'px');
6362                $container[0].style.setProperty('--jzsa-fullscreen-mosaic-strip-height', thumbSize + 'px');
6363                applyFullscreenMosaicSlideSize(thumbSize, vertical);
6364                if (vertical) {
6365                    $fullscreenMosaic.css({
6366                        left: '',
6367                        right: '',
6368                        top: (rail.startOffset + centeredOffset) + 'px',
6369                        bottom: 'auto',
6370                        width: thumbSize + 'px',
6371                        height: railLength + 'px'
6372                    });
6373                } else {
6374                    $fullscreenMosaic.css({
6375                        left: (rail.startOffset + centeredOffset) + 'px',
6376                        right: 'auto',
6377                        top: '',
6378                        bottom: '',
6379                        width: railLength + 'px',
6380                        height: thumbSize + 'px'
6381                    });
6382                }
6383                positionFullscreenMosaicPage(fullscreenMosaicPageStart);
6384            }
6385
6386            function applyExpandedMosaicSettings(settings) {
6387                viewerMosaicEnabled = settings.enabled;
6388                fullscreenMosaicPosition = settings.position;
6389                config.fullscreenMosaicCount = settings.count;
6390                config.fullscreenMosaicGap = settings.gap;
6391                config.fullscreenMosaicOpacity = settings.opacity;
6392                config.fullscreenMosaicBackground = settings.background;
6393
6394                $container.attr('data-fullscreen-mosaic', settings.enabled ? 'true' : 'false');
6395                $container.attr('data-fullscreen-mosaic-position', settings.position);
6396                $container.attr('data-fullscreen-mosaic-layout', settings.layout);
6397                $fullscreenMosaic[0].style.setProperty('--jzsa-mosaic-opacity', settings.opacity);
6398                if (settings.background) {
6399                    $fullscreenMosaic[0].style.setProperty('--jzsa-mosaic-background', settings.background);
6400                } else {
6401                    $fullscreenMosaic[0].style.removeProperty('--jzsa-mosaic-background');
6402                }
6403                if (typeof settings.cornerRadius === 'number' && settings.cornerRadius >= 0) {
6404                    $fullscreenMosaic[0].style.setProperty('--jzsa-fullscreen-mosaic-corner-radius', settings.cornerRadius + 'px');
6405                } else {
6406                    $fullscreenMosaic[0].style.removeProperty('--jzsa-fullscreen-mosaic-corner-radius');
6407                }
6408                resizeFullscreenMosaic();
6409                syncFullscreenMosaic();
6410                updateFullscreenMosaicState();
6411            }
6412
6413            resizeFullscreenMosaic();
6414
6415            function layoutVisibleFullscreenMosaic() {
6416                if (!$fullscreenMosaic.is(':visible')) {
6417                    return;
6418                }
6419                resizeFullscreenMosaic();
6420                syncFullscreenMosaic();
6421            }
6422
6423            function scheduleVisibleFullscreenMosaicLayout() {
6424                var raf = window.requestAnimationFrame || function(cb) { window.setTimeout(cb, 16); };
6425                raf(function() {
6426                    raf(layoutVisibleFullscreenMosaic);
6427                });
6428            }
6429
6430            $fullscreenMosaic.on('click', '.swiper-slide', function(e) {
6431                e.stopPropagation();
6432                var index = $(this).index();
6433                if (index < 0) {
6434                    return;
6435                }
6436                if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
6437                    swiper.slideToLoop(index);
6438                } else {
6439                    swiper.slideTo(index);
6440                }
6441            });
6442
6443            $fullscreenMosaic.on('click', '.jzsa-mosaic-arrow-prev', function(e) {
6444                e.preventDefault();
6445                e.stopPropagation();
6446                positionFullscreenMosaicPage(fullscreenMosaicPageStart - fullscreenMosaicEffectiveCount);
6447            });
6448
6449            $fullscreenMosaic.on('click', '.jzsa-mosaic-arrow-next', function(e) {
6450                e.preventDefault();
6451                e.stopPropagation();
6452                positionFullscreenMosaicPage(fullscreenMosaicPageStart + fullscreenMosaicEffectiveCount);
6453            });
6454
6455            function syncFullscreenMosaic() {
6456                var activeRealIndex = (typeof swiper.realIndex === 'number') ? swiper.realIndex : swiper.activeIndex;
6457                var activeThumbIndex = Math.min(activeRealIndex, fullscreenMosaicThumbCount - 1);
6458                $fullscreenMosaic.find('.swiper-slide').removeClass('swiper-slide-thumb-active');
6459                if (activeRealIndex < fullscreenMosaicThumbCount) {
6460                    $fullscreenMosaic.find('.swiper-slide').eq(activeThumbIndex).addClass('swiper-slide-thumb-active');
6461                }
6462                if (fullscreenMosaicEffectiveCount > 0) {
6463                    var pageStart = Math.floor(activeThumbIndex / fullscreenMosaicEffectiveCount) * fullscreenMosaicEffectiveCount;
6464                    positionFullscreenMosaicPage(pageStart);
6465                }
6466            }
6467
6468            function updateFullscreenMosaicState() {
6469                var isFs = isFullscreen($container[0]) || $container.hasClass('jzsa-is-fullscreen') || $container.hasClass('jzsa-pseudo-fullscreen');
6470                var isVisible = isFs && viewerMosaicEnabled;
6471                $fullscreenMosaic.attr('aria-hidden', isVisible ? 'false' : 'true');
6472                if (isVisible) {
6473                    scheduleVisibleFullscreenMosaicLayout();
6474                }
6475            }
6476
6477            swiper.on('slideChange', syncFullscreenMosaic);
6478            $container.on('jzsa:fullscreen-state.jzsaFullscreenMosaic-' + galleryId, updateFullscreenMosaicState);
6479            $(document).on(
6480                'fullscreenchange.jzsaFullscreenMosaic-' + galleryId +
6481                ' webkitfullscreenchange.jzsaFullscreenMosaic-' + galleryId +
6482                ' mozfullscreenchange.jzsaFullscreenMosaic-' + galleryId +
6483                ' MSFullscreenChange.jzsaFullscreenMosaic-' + galleryId,
6484                updateFullscreenMosaicState
6485            );
6486            $(window).on('resize.jzsaFullscreenMosaic-' + galleryId, function() {
6487                resizeFullscreenMosaic();
6488                syncFullscreenMosaic();
6489            });
6490            var fullscreenSettings = {
6491                enabled: config.fullscreenMosaic,
6492                position: config.fullscreenMosaicPosition,
6493                layout: config.fullscreenMosaicLayout,
6494                count: config.fullscreenMosaicCount,
6495                gap: config.fullscreenMosaicGap,
6496                opacity: config.fullscreenMosaicOpacity,
6497                background: config.fullscreenMosaicBackground,
6498                cornerRadius: config.fullscreenMosaicCornerRadius
6499            };
6500            var lightboxSettings = {
6501                enabled: config.lightboxMosaic,
6502                position: config.lightboxMosaicPosition,
6503                layout: config.lightboxMosaicLayout,
6504                count: config.lightboxMosaicCount,
6505                gap: config.lightboxMosaicGap,
6506                opacity: config.lightboxMosaicOpacity,
6507                background: config.lightboxMosaicBackground,
6508                cornerRadius: config.lightboxMosaicCornerRadius
6509            };
6510
6511            applyExpandedMosaicSettings(fullscreenSettings);
6512
6513            return {
6514                useFullscreen: function() {
6515                    applyExpandedMosaicSettings(fullscreenSettings);
6516                },
6517                useLightbox: function() {
6518                    applyExpandedMosaicSettings(lightboxSettings);
6519                }
6520            };
6521        }
6522
6523        // --------------------------------------------------------------------
6524        // Loading overlay: show a subtle loader until the first image is ready
6525        // --------------------------------------------------------------------
6526
6527        // Strip intro-fade so the container is visible (dashed outline) from the start.
6528        // Show the spinner immediately, but delay explanatory text so fast loads
6529        // stay visually quiet.
6530        $container
6531            .removeClass('jzsa-loaded jzsa-content-intro jzsa-content-intro-visible')
6532            .addClass('jzsa-loader-pending');
6533        activateLoaderState($container, 'Loading content...', 'This is taking longer than usual...');
6534
6535        var galleryLoaderShownAt = Date.now();
6536        var jzsaHasMarkedLoaded = false;
6537        var jzsaLoaderFallbackTimer = null;
6538        var jzsaLoaderCommitTimer = null;
6539        function clearGalleryLoaderTimers() {
6540            if (jzsaLoaderFallbackTimer) {
6541                window.clearTimeout(jzsaLoaderFallbackTimer);
6542                jzsaLoaderFallbackTimer = null;
6543            }
6544            if (jzsaLoaderCommitTimer) {
6545                window.clearTimeout(jzsaLoaderCommitTimer);
6546                jzsaLoaderCommitTimer = null;
6547            }
6548        }
6549        function commitGalleryLoaded() {
6550            clearGalleryLoaderTimers();
6551            resetLoaderState($container);
6552            $container
6553                .removeClass('jzsa-loader-pending')
6554                .addClass('jzsa-loaded');
6555        }
6556        function markGalleryLoaded() {
6557            if (jzsaHasMarkedLoaded) return;
6558            jzsaHasMarkedLoaded = true;
6559
6560            var minVisibleRemaining = 0;
6561            if (galleryLoaderShownAt > 0) {
6562                minVisibleRemaining = LOADER_MIN_VISIBLE_MS - (Date.now() - galleryLoaderShownAt);
6563            }
6564
6565            if (minVisibleRemaining > 0) {
6566                jzsaLoaderCommitTimer = window.setTimeout(commitGalleryLoaded, minVisibleRemaining);
6567            } else {
6568                commitGalleryLoaded();
6569            }
6570        }
6571
6572        function watchInitialPreviewLoad() {
6573            if (jzsaHasMarkedLoaded) {
6574                return;
6575            }
6576
6577            var $previewImages = $container.find('.jzsa-progressive-image');
6578            if (!$previewImages.length) {
6579                window.setTimeout(markGalleryLoaded, 800);
6580                return;
6581            }
6582
6583            var initialIndexInMarkup = $previewImages.length === allPhotos.length ? initialSlide : 0;
6584            var $initialPreviewImg = $previewImages.eq(initialIndexInMarkup);
6585            if (!$initialPreviewImg.length) {
6586                $initialPreviewImg = $previewImages.first();
6587            }
6588
6589            var imgEl = $initialPreviewImg[0];
6590            $initialPreviewImg.off('.jzsaLoader');
6591            if (imgEl.complete && imgEl.naturalWidth > 0) {
6592                // Image already in browser cache: wait for next paint so the
6593                // photo is actually on screen before the loader disappears.
6594                runOnNextPaint(markGalleryLoaded);
6595                return;
6596            }
6597
6598            $initialPreviewImg.one('load.jzsaLoader', function() {
6599                // Give the browser two frames to decode and commit the image
6600                // to the display before hiding the loader, avoiding a blank flash.
6601                runOnNextPaint(markGalleryLoaded);
6602            });
6603            $initialPreviewImg.one('error.jzsaLoader', function() {
6604                window.setTimeout(markGalleryLoaded, 800);
6605            });
6606        }
6607
6608        watchInitialPreviewLoad();
6609        jzsaLoaderFallbackTimer = window.setTimeout(markGalleryLoaded, 3500);
6610
6611        function finalizeSwiperInitialization() {
6612            // Swiper configuration - gather all parameters
6613            var swiperConfig = buildSwiperConfig({
6614                mode: mode,
6615                galleryId: galleryId,
6616                initialSlide: initialSlide,
6617                albumTitle: albumTitle,
6618                ZOOM_MAX_RATIO: ZOOM_MAX_RATIO,
6619                ZOOM_MIN_RATIO: ZOOM_MIN_RATIO,
6620                slideshow: slideshow,
6621                fullscreenSlideshow: fullscreenSlideshow,
6622                slideshowDelay: slideshowDelay,
6623                loop: loop,
6624                rewind: rewind,
6625                LAZY_LOAD_PREV_NEXT_AMOUNT: LAZY_LOAD_PREV_NEXT_AMOUNT,
6626                SWIPER_SPEED: SWIPER_SPEED,
6627                SLIDES_MOBILE: SLIDES_MOBILE,
6628                SPACING_MOBILE: SPACING_MOBILE,
6629                BREAKPOINT_MOBILE: BREAKPOINT_MOBILE,
6630                SLIDES_TABLET: SLIDES_TABLET,
6631                SPACING_TABLET: SPACING_TABLET,
6632                BREAKPOINT_TABLET: BREAKPOINT_TABLET,
6633                SLIDES_DESKTOP: SLIDES_DESKTOP,
6634                SPACING_DESKTOP: SPACING_DESKTOP,
6635                BREAKPOINT_DESKTOP: BREAKPOINT_DESKTOP,
6636                fullscreenToggle: fullscreenToggle,
6637                interactionLock: interactionLock
6638            });
6639
6640            // Add thumbs config if mosaic is enabled
6641            if (mosaicSwiper) {
6642                swiperConfig.thumbs = {
6643                    swiper: mosaicSwiper
6644                };
6645            }
6646
6647            // Initialize Swiper (pass the DOM element directly to avoid selector resolution issues)
6648            var swiper = new Swiper($container[0], swiperConfig);
6649            swipers[galleryId] = swiper;
6650            setupGalleryKeyboardOwnership($container, swiper, interactionLock);
6651            swiper._jzsaExpandedMosaicController = setupFullscreenMosaic(swiper);
6652
6653            // Sync mosaic with main gallery: scroll mosaic to keep active thumb visible
6654            if (mosaicSwiper) {
6655                swiper.on('slideChange', function() {
6656                    if (mosaicSwiper && !mosaicSwiper.destroyed) {
6657                        // With loop=true, activeIndex includes cloned slides.
6658                        // Use realIndex so mosaic paging stays aligned with real photos.
6659                        var activeRealIndex = (typeof swiper.realIndex === 'number') ? swiper.realIndex : swiper.activeIndex;
6660                        var pageStart = Math.floor(activeRealIndex / mosaicPageSize) * mosaicPageSize;
6661                        mosaicSwiper.slideTo(pageStart);
6662                    }
6663                });
6664            }
6665
6666            // Defensive guard: keep double-click/double-tap zoom disabled.
6667            // Pinch zoom remains available on touch devices via config.zoom.
6668            if (swiper.zoom && swiper.zoom.toggle) {
6669                swiper.zoom.toggle = function() {};
6670            }
6671
6672            // All info boxes: rendered at container level (like pagination).
6673            // Updated on every slide change so they don't animate with slides.
6674            var containerBoxes = [];
6675            var $topInfoStack = $('<div class="jzsa-info-stack jzsa-info-stack-top"></div>');
6676            var $bottomInfoStack = $('<div class="jzsa-info-stack jzsa-info-stack-bottom"></div>');
6677            var hasTopStackBoxes = false;
6678            var hasBottomStackBoxes = false;
6679
6680            function registerContainerBox(boxName) {
6681                if (boxName === 'info-bottom') {
6682                    return; // Driven by Swiper pagination, not a DOM box.
6683                }
6684                var boxFmt = $container.attr('data-' + boxName) || '';
6685                var boxFsFmt = $container.attr('data-fullscreen-' + boxName) || boxFmt;
6686                var boxLbFmt = $container.attr('data-lightbox-' + boxName) || boxFmt;
6687                if (!boxFmt && !boxFsFmt && !boxLbFmt) {
6688                    return;
6689                }
6690                var $box = $('<div class="jzsa-info-box jzsa-' + boxName + '"></div>');
6691                if (boxName === 'info-top' || boxName === 'info-top-secondary') {
6692                    $topInfoStack.append($box);
6693                    hasTopStackBoxes = true;
6694                } else {
6695                    $bottomInfoStack.append($box);
6696                    hasBottomStackBoxes = true;
6697                }
6698                containerBoxes.push({ $el: $box, fmt: boxFmt, fsFmt: boxFsFmt, lbFmt: boxLbFmt });
6699            }
6700
6701            for (var bi = 0; bi < SAFE_INFO_TOP_ORDER.length; bi++) {
6702                registerContainerBox(SAFE_INFO_TOP_ORDER[bi]);
6703            }
6704            for (var bj = 0; bj < SAFE_INFO_BOTTOM_ORDER.length; bj++) {
6705                registerContainerBox(SAFE_INFO_BOTTOM_ORDER[bj]);
6706            }
6707
6708            if (hasTopStackBoxes) {
6709                $container.append($topInfoStack);
6710            }
6711            if (hasBottomStackBoxes) {
6712                $container.append($bottomInfoStack);
6713            }
6714
6715            function updateAllInfoBoxes() {
6716                var isFs = $container.hasClass('jzsa-is-fullscreen') || $container.hasClass('jzsa-pseudo-fullscreen');
6717                var isLb = isLightboxPresentationActive($container[0]);
6718                var photoIndex = getSwiperPhotoIndex(swiper);
6719                var photo = getContainerPhoto($container, photoIndex);
6720                var total = getContainerTotalCount($container);
6721                var itemPlaceholders = total ? buildItemPlaceholders(mode, swiper, photoIndex + 1, total) : { item: '', items: '' };
6722                var albumTitle = $container.attr('data-album-title') || '';
6723                for (var j = 0; j < containerBoxes.length; j++) {
6724                    var cb = containerBoxes[j];
6725                    var fmt = isLb ? cb.lbFmt : (isFs ? cb.fsFmt : cb.fmt);
6726                    var text = renderInfoFormat(fmt, photo,
6727                        $.extend(itemPlaceholders, { albumTitle: albumTitle })
6728                    );
6729                    cb.$el.text(text).toggle(text !== '');
6730                }
6731            }
6732
6733            if (containerBoxes.length) {
6734                updateAllInfoBoxes();
6735                swiper.on('slideChange', updateAllInfoBoxes);
6736                swiper.on('slideChangeTransitionEnd', updateAllInfoBoxes);
6737                swiper.on('resize', updateAllInfoBoxes);
6738                $container.on('jzsa:fullscreen-state', updateAllInfoBoxes);
6739                $container.on('jzsa:exif-update', updateAllInfoBoxes);
6740            } else {
6741                updateAllInfoBoxes();
6742            }
6743
6744            refreshSwiperInfoBottom(swiper);
6745            swiper.on('slideChange', function() {
6746                refreshSwiperInfoBottom(swiper);
6747            });
6748            swiper.on('slideChangeTransitionEnd', function() {
6749                refreshSwiperInfoBottom(swiper);
6750            });
6751            swiper.on('resize', function() {
6752                refreshSwiperInfoBottom(swiper);
6753            });
6754            $container.on('jzsa:fullscreen-state', function() {
6755                refreshSwiperInfoBottom(swiper);
6756            });
6757            $container.on('jzsa:exif-update', function() {
6758                refreshSwiperInfoBottom(swiper);
6759            });
6760
6761            if (mode === 'carousel') {
6762                updateCarouselTileInfoBoxes($container, swiper, zoneFormats);
6763                swiper.on('slideChange', function() {
6764                    updateCarouselTileInfoBoxes($container, swiper, zoneFormats);
6765                });
6766                swiper.on('slideChangeTransitionEnd', function() {
6767                    updateCarouselTileInfoBoxes($container, swiper, zoneFormats);
6768                });
6769                swiper.on('resize', function() {
6770                    updateCarouselTileInfoBoxes($container, swiper, zoneFormats);
6771                });
6772                $container.on('jzsa:fullscreen-state', function() {
6773                    updateCarouselTileInfoBoxes($container, swiper, zoneFormats);
6774                });
6775                $container.on('jzsa:exif-update', function() {
6776                    updateCarouselTileInfoBoxes($container, swiper, zoneFormats);
6777                });
6778            }
6779
6780            if (mode === 'gallery') {
6781                $container.on('jzsa:exif-update', function() {
6782                    updateGalleryThumbnailInfoBoxes($container);
6783                });
6784            }
6785
6786            // Stop autoplay initially if inline slideshow is not 'auto' (e.g. 'manual' or 'disabled',
6787            // but fullscreen or manual mode still needs the autoplay module available)
6788            if (slideshow !== 'auto' && swiper.autoplay && swiper.autoplay.running) {
6789                swiper.autoplay.stop();
6790                // console.log('⏸️  Autoplay stopped (not auto-start inl
6790ine)');
6791            }
6792
6793            // Create hint system for fullscreen navigation guidance (not needed for button-only)
6794            var showHintsOnFullscreen = null;
6795            if (fullscreenToggle !== 'button-only') {
6796                showHintsOnFullscreen = createHintSystem(galleryId);
6797            }
6798            var showHintsOnLightbox = null;
6799            if (config.lightboxToggle && config.lightboxToggle !== 'button-only' && config.lightboxToggle !== 'disabled') {
6800                showHintsOnLightbox = createHintSystem(galleryId, 'jzsa-lightbox-hints-counter', [
6801                    'Click / tap / swipe left or right to browse photos',
6802                    'Press Esc or tap \u29C9 to close Lightbox'
6803                ]);
6804            }
6805
6806            var slideshowPausedByInteraction = false;
6807
6808            // ------------------------------------------------------------------------
6809            // Fullscreen change event listeners (all browser prefixes)
6810            // ------------------------------------------------------------------------
6811
6812            // Create params object for handleFullscreenChange
6813            var fullscreenChangeParams = {
6814                galleryId: galleryId,
6815                mode: mode,
6816                fullscreenSlideshow: fullscreenSlideshow,
6817                fullscreenSlideshowDelay: fullscreenSlideshowDelay,
6818                lightboxSlideshow: config.lightboxSlideshow,
6819                lightboxSlideshowDelay: config.lightboxSlideshowDelay,
6820                slideshow: slideshow,
6821                slideshowDelay: slideshowDelay,
6822                slideshowPausedByInteraction: slideshowPausedByInteraction,
6823                slideshowAutoresume: slideshowAutoresume,
6824                inlineSlideshowAutoresume: slideshowAutoresume,
6825                fullscreenSlideshowAutoresume: fullscreenSlideshowAutoresume,
6826                showNavigation: showNavigation,
6827                fullscreenShowNavigation: fullscreenShowNavigation,
6828                controlsColor: controlsColor,
6829                fullscreenControlsColor: fullscreenControlsColor,
6830                videoControlsColor: videoControlsColor,
6831                fullscreenVideoControlsColor: fullscreenVideoControlsColor,
6832                infoFontSize: config.infoFontSize,
6833                fullscreenInfoFontSize: config.fullscreenInfoFontSize,
6834                infoFontFamily: config.infoFontFamily,
6835                fullscreenInfoFontFamily: config.fullscreenInfoFontFamily,
6836                infoFontColor: config.infoFontColor,
6837                fullscreenInfoFontColor: config.fullscreenInfoFontColor,
6838                fullscreenDisplayMaxWidth: config.fullscreenDisplayMaxWidth,
6839                fullscreenDisplayMaxHeight: config.fullscreenDisplayMaxHeight,
6840                fullscreenCornerRadius: config.fullscreenCornerRadius,
6841                videoControlsAutohide: videoControlsAutohide,
6842                fullscreenVideoControlsAutohide: fullscreenVideoControlsAutohide,
6843                browserPrefix: null,
6844                _fullscreenActive: false,
6845                // For carousel mode: remember original layout so we can
6846                // temporarily switch to a single-slide view in fullscreen.
6847                originalSlidesPerView: null,
6848                originalBreakpoints: null,
6849                originalCenteredSlides: null
6850            };
6851            applyFullscreenDisplayOverrides($container[0], swiper, fullscreenChangeParams, false);
6852
6853            // Store on swiper so openLightbox / closeLightbox can apply and restore display settings.
6854            swiper._jzsaFullscreenParams = fullscreenChangeParams;
6855
6856            // Fullscreen change event listeners - Standard API (Chrome, Firefox, Edge)
6857            document.addEventListener('fullscreenchange', function() {
6858                fullscreenChangeParams.browserPrefix = null;
6859                fullscreenChangeParams.slideshowPausedByInteraction = slideshowPausedByInteraction;
6860                handleFullscreenChange($container[0], swiper, fullscreenChangeParams);
6861            });
6862
6863            // Webkit prefix (Safari, older Chrome/Android)
6864            document.addEventListener('webkitfullscreenchange', function() {
6865                fullscreenChangeParams.browserPrefix = 'webkit';
6866                fullscreenChangeParams.slideshowPausedByInteraction = slideshowPausedByInteraction;
6867                handleFullscreenChange($container[0], swiper, fullscreenChangeParams);
6868            });
6869
6870            // Mozilla prefix (Firefox)
6871            document.addEventListener('mozfullscreenchange', function() {
6872                fullscreenChangeParams.browserPrefix = 'moz';
6873                fullscreenChangeParams.slideshowPausedByInteraction = slideshowPausedByInteraction;
6874                handleFullscreenChange($container[0], swiper, fullscreenChangeParams);
6875            });
6876
6877            // MS prefix (old IE/Edge)
6878            document.addEventListener('MSFullscreenChange', function() {
6879                fullscreenChangeParams.browserPrefix = 'ms';
6880                fullscreenChangeParams.slideshowPausedByInteraction = slideshowPausedByInteraction;
6881                handleFullscreenChange($container[0], swiper, fullscreenChangeParams);
6882            });
6883            var fullscreenStateNamespace = '.jzsaFullscreenState-' + galleryId;
6884            $container.off('jzsa:fullscreen-state' + fullscreenStateNamespace);
6885            $container.on('jzsa:fullscreen-state' + fullscreenStateNamespace, function() {
6886                fullscreenChangeParams.browserPrefix = null;
6887                handleFullscreenChange($container[0], swiper, fullscreenChangeParams);
6888            });
6889
6890            // ------------------------------------------------------------------------
6891            // Fullscreen switch handlers (click/double-click to enter/exit fullscreen)
6892            // ------------------------------------------------------------------------
6893
6894            var fullscreenParams = {
6895                mode: mode,
6896                fullscreenToggle: fullscreenToggle,
6897                lightboxToggle: interactionLock ? 'disabled' : (config.lightboxToggle || 'disabled'),
6898                interactionLock: interactionLock,
6899                fullscreenSlideshow: fullscreenSlideshow,
6900                fullscreenSlideshowDelay: fullscreenSlideshowDelay,
6901                slideshowPausedByInteraction: slideshowPausedByInteraction,
6902                showHintsOnFullscreen: showHintsOnFullscreen,
6903                showHintsOnLightbox: showHintsOnLightbox
6904            };
6905
6906            setupFullscreenButton(swiper, $container, fullscreenParams);
6907            setupDownloadButton(swiper, $container);
6908            setupAutoplayProgress(swiper, $container);
6909            var togglePlayPause = setupPlayPauseButton(swiper, $container);
6910            setupFullscreenSwitchHandlers(swiper, $container, fullscreenParams);
6911
6912            // Desktop UX parity with gallery mode: pause inline autoplay while hovering
6913            // over the gallery, then resume when the pointer leaves.
6914            var hoverPauseSupported = !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches);
6915            var slideshowPausedByHover = false;
6916            var hoverPausedWithStopFallback = false;
6917            var suppressHoverPauseUntilLeave = false;
6918            var slideshowHoverNamespace = '.jzsaSlideshowHover-' + galleryId;
6919            var slideshowHoverFullscreenNamespace = '.jzsaSlideshowHoverFs-' + galleryId;
6920            $container.off('mouseenter' + slideshowHoverNamespace + ' mouseleave' + slideshowHoverNamespace);
6921
6922            function resetHoverAutoplayFlags() {
6923                slideshowPausedByHover = false;
6924                hoverPausedWithStopFallback = false;
6925            }
6926
6927            function shouldBlockHoverPause() {
6928                return suppressHoverPauseUntilLeave || isFullscreen($container[0]) || fullscreenChangeParams.slideshowPausedByInteraction;
6929            }
6930
6931            function setInlineAutoplayDelay() {
6932                if (!swiper.autoplay) {
6933                    return;
6934                }
6935                var normalDelay = slideshowDelay * MILLISECONDS_PER_SECOND;
6936                swiper.params.autoplay.delay = normalDelay;
6937                swiper.autoplay.delay = normalDelay;
6938            }
6939
6940            function resumeInlineAutoplay(forceStart) {
6941                if (!swiper.autoplay || fullscreenChangeParams.slideshowPau
6941sedByInteraction) {
6942                    return;
6943                }
6944
6945                setInlineAutoplayDelay();
6946
6947                if (!forceStart && typeof swiper.autoplay.resume === 'function' && swiper.autoplay.paused) {
6948                    swiper.autoplay.resume();
6949                    return;
6950                }
6951
6952                if (!swiper.autoplay.running || forceStart) {
6953                    swiper.autoplay.start();
6954                }
6955            }
6956
6957            function handleHoverFullscreenExit() {
6958                // Some browsers emit pointer/hover events while exiting fullscreen.
6959                // Ignore hover-based pause until cursor leaves the gallery once.
6960                suppressHoverPauseUntilLeave = true;
6961                resetHoverAutoplayFlags();
6962
6963                // Defensive recovery: after fullscreen exit, ensure inline autoplay is
6964                // actually advancing (some browsers can leave autoplay in paused/running state).
6965                if (slideshow !== 'auto' || !swiper.autoplay || fullscreenChangeParams.slideshowPausedByInteraction) {
6966                    return;
6967                }
6968
6969                window.setTimeout(function() {
6970                    if (isFullscreen($container[0]) || !swiper.autoplay || fullscreenChangeParams.slideshowPausedByInteraction) {
6971                        return;
6972                    }
6973
6974                    resumeInlineAutoplay(false);
6975                }, 80);
6976            }
6977
6978            $(document).off(
6979                'fullscreenchange' + slideshowHoverFullscreenNamespace +
6980                ' webkitfullscreenchange' + slideshowHoverFullscreenNamespace +
6981                ' mozfullscreenchange' + slideshowHoverFullscreenNamespace +
6982                ' MSFullscreenChange' + slideshowHoverFullscreenNamespace
6983            );
6984            $(document).on(
6985                'fullscreenchange' + slideshowHoverFullscreenNamespace +
6986                ' webkitfullscreenchange' + slideshowHoverFullscreenNamespace +
6987                ' mozfullscreenchange' + slideshowHoverFullscreenNamespace +
6988                ' MSFullscreenChange' + slideshowHoverFullscreenNamespace,
6989                function() {
6990                    if (!isFullscreen($container[0])) {
6991                        handleHoverFullscreenExit();
6992                    }
6993                }
6994            );
6995            $container.off('jzsa:fullscreen-state' + slideshowHoverFullscreenNamespace);
6996            $container.on('jzsa:fullscreen-state' + slideshowHoverFullscreenNamespace, function(_e, isActive) {
6997                if (!isActive) {
6998                    handleHoverFullscreenExit();
6999                }
7000            });
7001
7002            if (slideshow !== 'disabled' && hoverPauseSupported && swiper.autoplay && !interactionLock) {
7003                $container.on('mouseenter' + slideshowHoverNamespace, function() {
7004                    if (shouldBlockHoverPause()) {
7005                        return;
7006                    }
7007
7008                    if (swiper.autoplay.running) {
7009                        slideshowPausedByHover = true;
7010                        if (typeof swiper.autoplay.pause === 'function') {
7011                            hoverPausedWithStopFallback = false;
7012                            swiper.autoplay.pause();
7013                        } else {
7014                            hoverPausedWithStopFallback = true;
7015                            swiper.autoplay.stop();
7016                        }
7017                    }
7018                });
7019
7020                $container.on('mouseleave' + slideshowHoverNamespace, function() {
7021                    if (suppressHoverPauseUntilLeave) {
7022                        suppressHoverPauseUntilLeave = false;
7023                        resetHoverAutoplayFlags();
7024                        return;
7025                    }
7026
7027                    if (!slideshowPausedByHover || shouldBlockHoverPause()) {
7028                        return;
7029                    }
7030
7031                    slideshowPausedByHover = false;
7032
7033                    // If autoplay is no longer running, user likely stopped it manually.
7034                    // Do not auto-resume in that case (except stop/start fallback mode).
7035                    if (!swiper.autoplay || !swiper.autoplay.running) {
7036                        if (hoverPausedWithStopFallback && swiper.autoplay && !fullscreenChangeParams.slideshowPau
7036sedByInteraction) {
7037                            resumeInlineAutoplay(true);
7038                        }
7039                        hoverPausedWithStopFallback = false;
7040                        return;
7041                    }
7042
7043                    resumeInlineAutoplay(false);
7044                    hoverPausedWithStopFallback = false;
7045                });
7046            }
7047
7048            // ------------------------------------------------------------------------
7049            // Image error handling - Add error handlers to all images
7050            // ------------------------------------------------------------------------
7051
7052            $container.find('.jzsa-progressive-image').each(function() {
7053                var $img = $(this);
7054                // Handle errors on the preview image
7055                this.onerror = function() {
7056                    $img.addClass('jzsa-image-error');
7057                    // console.warn('Failed to load preview image:', $img.attr('src'));
7058                };
7059            });
7060
7061            // ------------------------------------------------------------------------
7062            // Pause slideshow when user clicks navigation buttons
7063            // ------------------------------------------------------------------------
7064
7065            $container.find('.swiper-button-next, .swiper-button-prev').on('click', function() {
7066                pauseAutoplayOnInteraction(swiper, fullscreenChangeParams);
7067            });
7068
7069            // ------------------------------------------------------------------------
7070            // Pause slideshow on actual swipe/drag gestures (not plain clicks/taps)
7071            // ------------------------------------------------------------------------
7072
7073            swiper.on('sliderFirstMove', function() {
7074                pauseAutoplayOnInteraction(swiper, fullscreenChangeParams);
7075            });
7076
7077            // ------------------------------------------------------------------------
7078            // Keyboard handlers
7079            // ------------------------------------------------------------------------
7080
7081            $(document).on('keydown', function(e) {
7082                if (interactionLock) {
7083                    return;
7084                }
7085
7086                // Spacebar - play/pause toggle (only in fullscreen)
7087                if (e.key === ' ' || e.keyCode === 32) {
7088                    if (isFullscreen()) {
7089                        e.preventDefault(); // Prevent page scroll
7090                        togglePlayPause();
7091                    }
7092                }
7093
7094                // Arrow keys - pause autoplay on navigation
7095                if (e.key === 'ArrowLeft' || e.keyCode === 37 || e.key === 'ArrowRight' || e.keyCode === 39) {
7096                    pauseAutoplayOnInteraction(swiper, fullscreenChangeParams);
7097                }
7098            });
7099
7100            // ------------------------------------------------------------------------
7101            // Progressive image loading
7102            // ------------------------------------------------------------------------
7103
7104            setupProgressiveImageLoading(swiper, $container);
7105
7106            // ------------------------------------------------------------------------
7107            // Video playback handling
7108            // ------------------------------------------------------------------------
7109
7110            setupVideoHandling(swiper, $container, fullscreenChangeParams);
7111            initPlyrInContainer($container);
7112            scheduleDurationPrefetch($container);
7113            scheduleExifPrefetch($container, zoneFormats);
7114            setupProgressiveSliderChunkLoading(swiper, $container, {
7115                enabled: progressiveSlider,
7116                chunkSize: config.progressiveChunkSize,
7117                totalCount: totalCount,
7118                zoneFormats: zoneFormats
7119            });
7120
7121            // CAROUSEL TOUCH REVEAL:
7122            // Passive listeners so iOS never blocks scroll. Same approach as gallery touch reveal.
7123            if (mode === 'carousel' && hasTouchInput()) {
7124                (function() {
7125                    var carouselEl = $container[0];
7126                    function closestSlide(target) {
7127                        return target && target.closest ? target.closest('.swiper-slide') : null;
7128                    }
7129                    function clearAllTouched() {
7130                        $container.find('.jzsa-slide-touched').each(function() {
7131                            clearTimeout($(this).data('jzsaSlideRevealTimer'));
7132                            $(this).removeData('jzsaSlideRevealTimer').removeClass('jzsa-slide-touched');
7133                        });
7134                    }
7135                    carouselEl.addEventListener('touchstart', function(e) {
7136                        var slide = closestSlide(e.target);
7137                        if (!slide) return;
7138                        var t = e.touches[0];
7139                        $(slide).data('jzsaRevealTouchX', t.clientX).data('jzsaRevealTouchY', t.clientY);
7140                    }, { passive: true });
7141                    carouselEl.addEventListener('touchend', function(e) {
7142                        var slide = closestSlide(e.target);
7143                        if (!slide) return;
7144                        // Skip Swiper loop clones - they are repositioned without transition events.
7145                        if (slide.classList.contains('swiper-slide-duplicate')) return;
7146                        var $slide = $(slide);
7147                        var startX = $slide.data('jzsaRevealTouchX');
7148                        var startY = $slide.data('jzsaRevealTouchY');
7149                        $slide.removeData('jzsaRevealTouchX jzsaRevealTouchY');
7150                        if (startX == null) return;
7151                        var t = e.changedTouches[0];
7152                        if (Math.abs(t.clientX - startX) > 8 || Math.abs(t.clientY - startY) > 8) return;
7153                        $container.find('.jzsa-slide-touched').not($slide).each(function() {
7154                            clearTimeout($(this).data('jzsaSlideRevealTimer'));
7155                            $(this).removeData('jzsaSlideRevealTimer').removeClass('jzsa-slide-touched');
7156                        });
7157                        $slide.addClass('jzsa-slide-touched');
7158                        clearTimeout($slide.data('jzsaSlideRevealTimer'));
7159                        $slide.data('jzsaSlideRevealTimer', setTimeout(function() {
7160                            $slide.removeData('jzsaSlideRevealTimer').removeClass('jzsa-slide-touched');
7161                        }, 3000));
7162                    }, { passive: true });
7163                    carouselEl.addEventListener('touchcancel', function(e) {
7164                        var slide = closestSlide(e.target);
7165                        if (!slide) return;
7166                        $(slide).removeData('jzsaRevealTouchX jzsaRevealTouchY');
7167                    }, { passive: true });
7168                    // Clear touched state when a swipe transition starts.
7169                    swiper.on('slideChangeTransitionStart', clearAllTouched);
7170                }());
7171            }
7172
7173            // console.log('✅ Swiper initialized:', galleryId);
7174            // console.log('  - Normal mode slideshow:', slideshow ? 'Enabled (delay: ' + slideshowDelay + 's)' : 'Disabled');
7175            // console.log('  - Fullscreen mode autoplay:', fullscreenSlideshow ? 'Enabled (delay: ' + fullscreenSlideshowDelay + 's)' : 'Disabled');
7176            // console.log('  - Loop: Always enabled');
7177            // console.log('  - Zoom: Pinch-to-zoom on touch devices (double-click disabled)');
7178            // console.log('  - Fullscreen: ' + (fullscreenToggle === 'button-only' ? 'Button only' : fullscreenToggle === 'double-click' ? 'Double-click or button' : 'Click or button'));
7179            // console.log('  - Progressive loading: Preview → Full resolution');
7180
7181            return swiper;
7182        }
7183
7184        if (useDeferredSingleFirstPaint) {
7185            var deferInit = window.requestAnimationFrame || function(callback) {
7186                window.setTimeout(callback, 16);
7187            };
7188            deferInit(function() {
7189                if (!$container.closest('html').length) {
7190                    return;
7191                }
7192                $container.find('.swiper-wrapper').html(buildSlidesHtml(allPhotos, slidesRenderOptions));
7193                watchInitialPreviewLoad();
7194                finalizeSwiperInitialization();
7195            });
7196            return null;
7197        }
7198
7199        return finalizeSwiperInitialization();
7200    }
7201
7202    // ============================================================================
7203    // GALLERY MODE - thumbnail gallery + fullscreen slideshow
7204    // ============================================================================
7205
7206    /**
7207     * Build a hidden player container for a thumbnail gallery.
7208     * Uses the same DOM structure as build_gallery_container in PHP so that
7209     * initializeSwiper can power it with the full feature set.
7210     *
7211     * @param  {jQuery} $galleryContainer The gallery container element.
7212     * @return {string} The player container ID.
7213     */
7214    function buildGallerySlideshow($galleryContainer) {
7215        var galleryId = $galleryContainer.attr('id');
7216        var slideshowId = galleryId + '-slideshow';
7217
7218        // Build full player DOM structure (mirrors PHP build_gallery_container)
7219        var html =
7220            '<div id="' + slideshowId + '" class="jzsa-album swiper jzsa-gallery-slideshow jzsa-loader-pending jzsa-content-intro">' +
7221                '<div class="swiper-wrapper"></div>' +
7222                '<div class="swiper-button-prev"></div>' +
7223                '<div class="swiper-button-next"></div>' +
7224                '<div class="swiper-pagination"></div>' +
7225                '<button class="swiper-button-play-pause" title="' + jzsaEscapeAttr(jzsaI18n('playPauseSpace')) + '"></button>' +
7226                '<div class="swiper-slideshow-progress"><div class="swiper-slideshow-progress-bar"></div></div>';
7227
7228        // External link button (render if enabled in inline, fullscreen, or lightbox contexts;
7229        // CSS decides visibility based on current fullscreen/lightbox state).
7230        var showLink = $galleryContainer.attr('data-show-link-button') === 'true';
7231        var showFullscreenLink = readBooleanDataAttr(
7232            $galleryContainer,
7233            'data-fullscreen-show-link-button',
7234            showLink
7235        );
7236        var showLightboxLink = $galleryContainer.attr('data-lightbox-show-link-button') === 'true';
7237        var albumUrl = $galleryContainer.attr('data-album-url') || '';
7238        if ((showLink || showFullscreenLink || showLightboxLink) && albumUrl) {
7239            html += '<a href="' + albumUrl + '" target="_blank" rel="noopener noreferrer" ' +
7240                'class="swiper-button-external-link" title="' + jzsaEscapeAttr(jzsaI18n('openInGooglePhotos')) + '"></a>';
7241        }
7242
7243        // Download button (same visibility split logic as link button).
7244        var showDownload = $galleryContainer.attr('data-show-download-button') === 'true';
7245        var showFullscreenDownload = readBooleanDataAttr(
7246            $galleryContainer,
7247            'data-fullscreen-show-download-button',
7248            showDownload
7249        );
7250        var showLightboxDownload = $galleryContainer.attr('data-lightbox-show-download-button') === 'true';
7251        if (showDownload || showFullscreenDownload || showLightboxDownload) {
7252            html += '<button class="swiper-button-download" title="' + jzsaEscapeAttr(jzsaI18n('downloadCurrentMedia')) + '"></button>';
7253        }
7254
7255        // Mirror PHP renderer logic: lightbox button and/or fullscreen button.
7256        var galLightboxToggle = $galleryContainer.attr('data-lightbox-toggle') || 'disabled';
7257        var galLightboxOn     = galLightboxToggle !== 'disabled';
7258        var galFsToggle       = $galleryContainer.attr('data-fullscreen-toggle') || 'button-only';
7259        var galFsEnabled      = galFsToggle && galFsToggle !== 'disabled';
7260        var galShowFullscreen = galFsEnabled || galLightboxOn;
7261        if (galLightboxOn) {
7262            html += '<div class="swiper-button-lightbox"></div>';
7263        }
7264        if (galShowFullscreen) {
7265            html += '<div class="swiper-button-fullscreen"></div>';
7266        }
7267        html += '</div>';
7268
7269        // Insert player after the gallery shell when present so controls can stay
7270        // overlaid on top of the gallery without affecting player placement.
7271        var $shell = $galleryContainer.parent('.jzsa-gallery-shell');
7272        if ($shell.length) {
7273            $shell.after(html);
7274        } else {
7275            $galleryContainer.after(html);
7276        }
7277
7278        var $slideshow = $('#' + slideshowId);
7279
7280        if (galLightboxOn && galShowFullscreen) {
7281            $slideshow.addClass('jzsa-has-dual-expand');
7282        }
7283
7284        // Copy data attributes for initializeSwiper
7285        $slideshow.attr('data-all-photos', $galleryContainer.attr('data-all-photos'));
7286        $slideshow.attr('data-total-count', $galleryContainer.attr('data-total-count'));
7287        $slideshow.attr('data-mode', 'slider');
7288        $slideshow.attr('data-start-at', $galleryContainer.attr('data-start-at') || '1');
7289        // Gallery has no inline slideshow - use fullscreen slideshow settings
7290        $slideshow.attr('data-slideshow', 'disabled');
7291
7292        // Forward player-relevant settings from the gallery container
7293        var forwardAttrs = [
7294            'data-fullscreen-slideshow',
7295            'data-fullscreen-slideshow-delay',
7296            'data-slideshow-autoresume',
7297            'data-fullscreen-slideshow-autoresume',
7298            'data-fullscreen-toggle',
7299            'data-interaction-lock',
7300            'data-show-navigation',
7301            'data-fullscreen-show-navigation',
7302            'data-album-title',
7303            'data-album-url',
7304            'data-image-fit',
7305            'data-fullscreen-image-fit',
7306            'data-fullscreen-mosaic',
7307            'data-fullscreen-mosaic-position',
7308            'data-fullscreen-mosaic-count',
7309            'data-fullscreen-mosaic-gap',
7310            'data-fullscreen-mosaic-opacity',
7311            'data-fullscreen-mosaic-corner-radius',
7312            'data-fullscreen-display-max-width',
7313            'data-fullscreen-display-max-height',
7314            'data-fullscreen-corner-radius',
7315            'data-background-color',
7316            'data-fullscreen-background-color',
7317            'data-controls-color',
7318            'data-fullscreen-controls-color',
7319            'data-video-controls-color',
7320            'data-fullscreen-video-controls-color',
7321            'data-video-controls-autohide',
7322            'data-fullscreen-video-controls-autohide',
7323            'data-show-download-button',
7324            'data-show-link-button',
7325            'data-fullscreen-show-download-button',
7326            'data-fullscreen-show-link-button',
7327            'data-download-size-warning',
7328            'data-info-font-size',
7329            'data-fullscreen-info-font-size',
7330            'data-info-font-family',
7331            'data-fullscreen-info-font-family',
7332            'data-info-font-color',
7333            'data-fullscreen-info-font-color',
7334            'data-info-wrap',
7335            'data-info-text-align',
7336            'data-info-top-text-align',
7337            'data-info-top-secondary-text-align',
7338            'data-info-bottom-text-align',
7339            'data-info-halo-effect',
7340            'data-info-top-halo-effect',
7341            'data-info-top-secondary-halo-effect',
7342            'data-info-bottom-halo-effect',
7343            'data-gallery-info-bottom-halo-effect',
7344            'data-album-title-halo-effect',
7345            'data-info-top',
7346            'data-fullscreen-info-top',
7347            'data-info-top-secondary',
7348            'data-fullscreen-info-top-secondary',
7349            'data-info-bottom',
7350            'data-fullscreen-info-bottom',
7351            'data-has-active-bottom-center',
7352            // Lightbox (alternative to native fullscreen).
7353            'data-lightbox-toggle',
7354            'data-lightbox-fullscreen',
7355            'data-lightbox-image-fit',
7356            'data-lightbox-max-width',
7357            'data-lightbox-max-height',
7358            'data-lightbox-background-color',
7359            'data-lightbox-backdrop-color',
7360            'data-lightbox-corner-radius',
7361            'data-lightbox-controls-color',
7362            'data-lightbox-video-controls-color',
7363            'data-lightbox-video-controls-autohide',
7364            'data-lightbox-show-navigation',
7365            'data-lightbox-show-link-button',
7366            'data-lightbox-show-download-button',
7367            'data-lightbox-slideshow',
7368            'data-lightbox-slideshow-delay',
7369            'data-lightbox-slideshow-autoresume',
7370            'data-lightbox-info-top',
7371            'data-lightbox-info-top-secondary',
7372            'data-lightbox-info-bottom',
7373            'data-lightbox-info-font-size',
7374            'data-lightbox-info-font-family',
7375            'data-lightbox-info-font-color',
7376            'data-lightbox-mosaic',
7377            'data-lightbox-mosaic-position',
7378            'data-lightbox-mosaic-layout',
7379            'data-lightbox-mosaic-count',
7380            'data-lightbox-mosaic-gap',
7381            'data-lightbox-mosaic-opacity',
7382            'data-lightbox-mosaic-background',
7383            'data-lightbox-mosaic-corner-radius',
7384            'data-lightbox-source-width',
7385            'data-lightbox-source-height'
7386        ];
7387        for (var i = 0; i < forwardAttrs.length; i++) {
7388            var val = $galleryContainer.attr(forwardAttrs[i]);
7389            if (val !== undefined) {
7390                $slideshow.attr(forwardAttrs[i], val);
7391            }
7392        }
7393        $slideshow.attr(
7394            'data-info-halo-effect',
7395            readBooleanDataAttr($galleryContainer, 'data-info-halo-effect', true) ? 'true' : 'false'
7396        );
7397
7398        // Forward --gallery-bg-color CSS custom property for fullscreen background
7399        // Prefer fullscreen-background-color for the slideshow (which is always fullscreen)
7400        var fsBgColor = $galleryContainer.attr('data-fullscreen-background-color');
7401        var bgColor = fsBgColor || $galleryContainer.attr('data-background-color');
7402        if (bgColor) {
7403            $slideshow[0].style.setProperty('--gallery-bg-color', bgColor);
7404        }
7405        var controlsColor = $galleryContainer.attr('data-fullscreen-controls-color') || $galleryContainer.attr('data-controls-color');
7406        if (controlsColor) {
7407            $slideshow[0].style.setProperty('--jzsa-controls-color', controlsColor);
7408            applyControlsColorToIcons('#' + $slideshow.attr('id'), controlsColor);
7409        }
7410        var videoControlsColor = $galleryContainer.attr('data-fullscreen-video-controls-color') || $galleryContainer.attr('data-video-controls-color');
7411        if (videoControlsColor) {
7412            $slideshow[0].style.setProperty('--jzsa-video-controls-color', videoControlsColor);
7413        }
7414        var infoFontSize = parseInt($galleryContainer.attr('data-fullscreen-info-font-size'), 10);
7415        if (isNaN(infoFontSize) || infoFontSize <= 0) {
7416            infoFontSize = parseInt($galleryContainer.attr('data-info-font-size'), 10);
7417        }
7418        if (!isNaN(infoFontSize) && infoFontSize > 0) {
7419            $slideshow[0].style.setProperty('--jzsa-info-font-size', infoFontSize + 'px');
7420        } else {
7421            var inheritedInfoFontSize = $galleryContainer[0].style.getPropertyValue('--jzsa-info-font-size');
7422            if (inheritedInfoFontSize) {
7423                $slideshow[0].style.setProperty('--jzsa-info-font-size', inheritedInfoFontSize);
7424            }
7425        }
7426        var infoFontFamily = ($galleryContainer.attr('data-fullscreen-info-font-family') || $galleryContainer.attr('data-info-font-family') || '').trim();
7427        if (infoFontFamily) {
7428            $slideshow[0].style.setProperty('--jzsa-info-font-family', infoFontFamily);
7429        } else {
7430            var inheritedInfoFontFamily = $galleryContainer[0].style.getPropertyValue('--jzsa-info-font-family');
7431            if (inheritedInfoFontFamily) {
7432                $slideshow[0].style.setProperty('--jzsa-info-font-family', inheritedInfoFontFamily);
7433            }
7434        }
7435        var infoFontColor = ($galleryContainer.attr('data-fullscreen-info-font-color') || $galleryContainer.attr('data-info-font-color') || '').trim();
7436        if (infoFontColor) {
7437            $slideshow[0].style.setProperty('--jzsa-info-font-color', infoFontColor);
7438        } else {
7439            var inheritedInfoFontColor = $galleryContainer[0].style.getPropertyValue('--jzsa-info-font-color');
7440            if (inheritedInfoFontColor) {
7441                $slideshow[0].style.setProperty('--jzsa-info-font-color', inheritedInfoFontColor);
7442            }
7443        }
7444        var fullscreenMosaicCornerRadius = parseInt($galleryContainer.attr('data-fullscreen-mosaic-corner-radius'), 10);
7445        if (!isNaN(fullscreenMosaicCornerRadius) && fullscreenMosaicCornerRadius >= 0) {
7446            $slideshow[0].style.setProperty('--jzsa-fullscreen-mosaic-corner-radius', fullscreenMosaicCornerRadius + 'px');
7447        } else {
7448            var inheritedFullscreenMosaicCornerRadius = $galleryContainer[0].style.getPropertyValue('--jzsa-fullscreen-mosaic-corner-radius');
7449            if (inheritedFullscreenMosaicCornerRadius) {
7450                $slideshow[0].style.setProperty('--jzsa-fullscreen-mosaic-corner-radius', inheritedFullscreenMosaicCornerRadius);
7451            }
7452        }
7453
7454        return slideshowId;
7455    }
7456
7457    /**
7458     * Parse Google Photos aspect ratio from a URL like "…=w800-h600".
7459     * Returns width/height ratio, defaulting to 4/3.
7460     *
7461     * @param  {string} url Photo URL.
7462     * @return {number} Aspect ratio (width / height).
7463     */
7464    function parseAspectRatio(url) {
7465        var match = url.match(/=w(\d+)-h(\d+)/);
7466        if (match) {
7467            var w = parseInt(match[1], 10);
7468            var h = parseInt(match[2], 10);
7469            if (w > 0 && h > 0) {
7470                return w / h;
7471            }
7472        }
7473        return 4 / 3; // safe fallback
7474    }
7475
7476    /**
7477     * Group photos into rows for a justified gallery.
7478     * Each row fills approximately `containerWidth` when photos are scaled to `targetHeight`.
7479     *
7480     * @param  {Array}  photos         Array of {photo, ratio, index}.
7481     * @param  {number} containerWidth Available pixel width.
7482     * @param  {number} targetHeight   Desired row height in pixels.
7483     * @param  {number} gap            Pixel gap between photos.
7484     * @return {Array}  Array of rows, each row being an array of photo items.
7485     */
7486    function buildJustifiedRows(photos, containerWidth, targetHeight, gap) {
7487        var rows = [];
7488        var currentRow = [];
7489        var currentRowWidth = 0;
7490
7491        photos.forEach(function(item) {
7492            var photoWidth = targetHeight * item.ratio;
7493            var gapBefore = currentRow.length > 0 ? gap : 0;
7494
7495            // Start a new row when adding this photo would exceed ~110% of container width
7496            if (currentRow.length > 0 && currentRowWidth + gapBefore + photoWidth > containerWidth * 1.1) {
7497                rows.push(currentRow);
7498                currentRow = [];
7499                currentRowWidth = 0;
7500                gapBefore = 0;
7501            }
7502
7503            currentRow.push(item);
7504            currentRowWidth += gapBefore + photoWidth;
7505        });
7506
7507        if (currentRow.length > 0) {
7508            rows.push(currentRow);
7509        }
7510
7511        return rows;
7512    }
7513
7514    /**
7515     * Render a grid CSS gallery of thumbnails into `$container`.
7516     *
7517     * @param {jQuery} $container Gallery album element.
7518     * @param {Array}  pageItems  Array of objects: { photo, index }.
7519     */
7520    function buildUniformGallery($container, pageItems, options) {
7521        var renderOptions = options || {};
7522        var placeholderCount = parseInt(renderOptions.placeholderCount, 10);
7523        if (isNaN(placeholderCount) || placeholderCount < 0) {
7524            placeholderCount = 0;
7525        }
7526        var tileHeight = parseFloat(renderOptions.tileHeight);
7527        if (!isFinite(tileHeight) || tileHeight <= 0) {
7528            tileHeight = 0;
7529        }
7530        var tileHeightValue = tileHeight > 0 ? (Math.round(tileHeight * 1000) / 1000) + 'px' : '';
7531        var tileStyleAttr = tileHeightValue ? ' style="height:' + tileHeightValue + ';"' : '';
7532        var tileFillClass = tileHeightValue ? ' jzsa-gallery-tile-fill' : '';
7533
7534        var columns       = parseInt(readGalleryAttr($container, 'columns'), 10) || 3;
7535        var columnsTablet = parseInt(readGalleryAttr($container, 'columns-tablet'), 10) || 2;
7536        var columnsMobile = parseInt(readGalleryAttr($container, 'columns-mobile'), 10) || 1;
7537        // Pass column counts and gap as CSS custom properties so the media queries pick them up
7538        var galleryGap = parseInt(readGalleryAttr($container, 'gap'), 10) || 4;
7539        $container[0].style.setProperty('--jzsa-gallery-columns',        columns);
7540        $container[0].style.setProperty('--jzsa-gallery-columns-tablet', columnsTablet);
7541        $container[0].style.setProperty('--jzsa-gallery-columns-mobile', columnsMobile);
7542        $container[0].style.setProperty('--jzsa-gallery-gap',            galleryGap + 'px');
7543        var notLocked = $container.attr('data-interaction-lock') !== 'true';
7544        var thumbLightboxOn = notLocked && !!$container.attr('data-lightbox-toggle') && $container.attr('data-lightbox-toggle') !== 'disabled';
7545        var thumbFsOn = notLocked && $container.attr('data-fullscreen-toggle') !== 'disabled';
7546        var thumbDualExpand = thumbLightboxOn && thumbFsOn;
7547        var allowThumbFullscreen = thumbLightboxOn || thumbFsOn;
7548        var thumbExpandI18nKey = thumbLightboxOn ? 'openMediaLightbox' : 'openMediaFullscreen';
7549        $container.toggleClass('jzsa-has-dual-expand', thumbDualExpand);
7550        var showThumbLink = $container.attr('data-show-link-button') === 'true' && notLocked;
7551        var showThumbDownload = $container.attr('data-show-download-button') === 'true' && notLocked;
7552        var thumbAlbumUrl = $container.attr('data-album-url') || '';
7553
7554        var html = '';
7555        pageItems.forEach(function(item) {
7556            var photo = item.photo;
7557            var globalIndex = item.index;
7558            var src = photo.preview || photo.full;
7559            var isVideo = photo.type === 'video';
7560            var itemClass = 'jzsa-gallery-item' + (isVideo ? ' jzsa-gallery-item-video' : '');
7561            var mediaLabel = isVideo ? 'video' : 'photo';
7562            var mediaHtml;
7563
7564            if (isVideo) {
7565                var videoSrc = photo.video || src;
7566                mediaHtml = buildVideoHtml({ src: videoSrc, poster: src, mediaIndex: globalIndex });
7567            } else {
7568                mediaHtml =
7569                    '<img class="jzsa-gallery-thumb' + tileFillClass + '"' +
7570                    ' src="' + src + '"' +
7571                    (src !== photo.full ? ' data-full-src="' + photo.full + '"' : '') +
7572                    ' data-index="' + globalIndex + '"' +
7573                    ' alt="' + mediaLabel.charAt(0).toUpperCase() + mediaLabel.slice(1) + ' ' + (globalIndex + 1) + '"' +
7574                    ' draggable="false"' +
7575                    (IS_IOS ? '' : ' loading="lazy"') +
7576                    ' decoding="' + (IS_IOS ? 'sync' : 'async') + '"' +
7577                    ' referrerpolicy="no-referrer"' + tileStyleAttr + '>';
7578            }
7579
7580            var thumbOverlayBtns = '';
7581            if (showThumbLink && thumbAlbumUrl) {
7582                thumbOverlayBtns += '<a href="' + thumbAlbumUrl + '" target="_blank" rel="noopener noreferrer" class="jzsa-gallery-thumb-link-btn swiper-button-external-link" tabindex="0" aria-label="' + jzsaEscapeAttr(jzsaI18n('openAlbumGooglePhotos')) + '"></a>';
7583            }
7584            if (showThumbDownload) {
7585                thumbOverlayBtns += '<div class="jzsa-gallery-thumb-download-btn swiper-button-download" role="button" tabindex="0" data-index="' + globalIndex + '" data-media-type="' + mediaLabel + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('downloadMedia', globalIndex + 1)) + '"></div>';
7586            }
7587            if (allowThumbFullscreen) {
7588                if (thumbDualExpand) {
7589                    thumbOverlayBtns += '<div class="jzsa-gallery-thumb-fs-btn swiper-button-lightbox" role="button" tabindex="0" data-index="' + globalIndex + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('openMediaLightbox', globalIndex + 1)) + '"></div>';
7590                    thumbOverlayBtns += '<div class="jzsa-gallery-thumb-fs-btn swiper-button-fullscreen" role="button" tabindex="0" data-index="' + globalIndex + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('openMediaFullscreen', globalIndex + 1)) + '"></div>';
7591                } else {
7592                    var singleExpandClass = thumbLightboxOn ? 'swiper-button-lightbox' : 'swiper-button-fullscreen';
7593                    thumbOverlayBtns += '<div class="jzsa-gallery-thumb-fs-btn ' + singleExpandClass + '" role="button" tabindex="0" data-index="' + globalIndex + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n(thumbExpandI18nKey, globalIndex + 1)) + '"></div>';
7594                }
7595            }
7596
7597            // Info box overlays for gallery thumbnails.
7598            var thumbZoneFormats = readInfoZoneFormats($container);
7599            var thumbZoneHtml = buildInfoZoneHtml(photo, thumbZoneFormats.inline, thumbZoneFormats.fullscreen, {
7600                item: String(globalIndex + 1),
7601                items: String(parseInt($container.attr('data-total-count'), 10) || 0),
7602                albumTitle: $container.attr('data-album-title') || ''
7603            }, { bottomOrder: GALLERY_INFO_BOTTOM_ORDER });
7604
7605            html +=
7606                '<div class="' + itemClass + '" data-index="' + globalIndex + '">' +
7607                    mediaHtml +
7608                    thumbZoneHtml +
7609                    thumbOverlayBtns +
7610                '</div>';
7611        });
7612
7613        for (var i = 0; i < placeholderCount; i++) {
7614            html += '<div class="jzsa-gallery-placeholder' + tileFillClass + '" aria-hidden="true"' + tileStyleAttr + '></div>';
7615        }
7616
7617        renderGalleryMarkup($container, html);
7618    }
7619
7620    /**
7621     * Render gallery HTML while preserving transient overlay nodes (like loader).
7622     *
7623     * @param {jQuery} $container Gallery album element.
7624     * @param {string} html       Gallery markup to render.
7625     */
7626    function renderGalleryMarkup($container, html) {
7627        // Pause any playing videos before destroying DOM to prevent orphan audio
7628        $container.find('video.jzsa-video-player').each(function() {
7629            if (this._jzsaPlyr && this._jzsaPlyr.playing) {
7630                this._jzsaPlyr.pause();
7631            }
7632        });
7633        destroyPlyrInContainer($container);
7634        var $loader = $container.children('.jzsa-loader').detach();
7635        $container.html(html);
7636        if ($loader.length) {
7637            $container.append($loader);
7638        }
7639        initPlyrInContainer($container);
7640        scheduleDurationPrefetch($container);
7641    }
7642
7643    /**
7644     * Measure gallery width with a safe fallback.
7645     *
7646     * @param {jQuery} $container Gallery album element.
7647     * @return {number}
7648     */
7649    function getGalleryContainerWidth($container) {
7650        var containerWidth = $container.width();
7651        if (!containerWidth || containerWidth < 10) {
7652            return 800;
7653        }
7654        return containerWidth;
7655    }
7656
7657    /**
7658     * Render precomputed justified rows into `$container`.
7659     *
7660     * @param {jQuery} $container      Gallery album element.
7661     * @param {Array}  rows            Array of justified rows.
7662     * @param {number} containerWidth  Available width in pixels.
7663     * @param {number} targetHeight    Row height in pixels.
7664     * @param {number} gap             Gap between thumbnails in pixels.
7665     */
7666    function renderJustifiedRows($container, rows, containerWidth, targetHeight, gap) {
7667        var notLocked = $container.attr('data-interaction-lock') !== 'true';
7668        var thumbLightboxOn = notLocked && !!$container.attr('data-lightbox-toggle') && $container.attr('data-lightbox-toggle') !== 'disabled';
7669        var thumbFsOn = notLocked && $container.attr('data-fullscreen-toggle') !== 'disabled';
7670        var thumbDualExpand = thumbLightboxOn && thumbFsOn;
7671        var allowThumbFullscreen = thumbLightboxOn || thumbFsOn;
7672        var thumbExpandI18nKey = thumbLightboxOn ? 'openMediaLightbox' : 'openMediaFullscreen';
7673        $container.toggleClass('jzsa-has-dual-expand', thumbDualExpand);
7674        var showThumbLink = $container.attr('data-show-link-button') === 'true' && notLocked;
7675        var showThumbDownload = $container.attr('data-show-download-button') === 'true' && notLocked;
7676        var thumbAlbumUrl = $container.attr('data-album-url') || '';
7677        var html = '';
7678        rows.forEach(function(row) {
7679            var totalRatio    = row.reduce(function(sum, item) { return sum + item.ratio; }, 0);
7680            var totalGap      = gap * (row.length - 1);
7681            var availableWidth = containerWidth - totalGap;
7682
7683            html += '<div class="jzsa-justified-row">';
7684            row.forEach(function(item) {
7685                var width = Math.round((item.ratio / totalRatio) * availableWidth);
7686                var src   = item.photo.preview || item.photo.full;
7687                var isVideo = item.photo.type === 'video';
7688                var itemClass = 'jzsa-gallery-item' + (isVideo ? ' jzsa-gallery-item-video' : '');
7689                var mediaLabel = isVideo ? 'video' : 'photo';
7690                var mediaHtml;
7691
7692                if (isVideo) {
7693                    var videoSrc = item.photo.video || src;
7694                    mediaHtml = buildVideoHtml({ src: videoSrc, poster: src, mediaIndex: item.index });
7695                } else {
7696                    mediaHtml =
7697                        '<img class="jzsa-gallery-thumb jzsa-justified-thumb"' +
7698                        ' src="' + src + '"' +
7699                        (src !== item.photo.full ? ' data-full-src="' + item.photo.full + '"' : '') +
7700                        ' data-index="' + item.index + '"' +
7701                        ' alt="' + mediaLabel.charAt(0).toUpperCase() + mediaLabel.slice(1) + ' ' + (item.index + 1) + '"' +
7702                        ' draggable="false"' +
7703                        (IS_IOS ? '' : ' loading="lazy"') +
7704                        ' decoding="' + (IS_IOS ? 'sync' : 'async') + '"' +
7705                        ' referrerpolicy="no-referrer"' +
7706                        ' style="width:100%;height:100%;">';
7707                }
7708
7709                var thumbOverlayBtns = '';
7710                if (showThumbLink && thumbAlbumUrl) {
7711                    thumbOverlayBtns += '<a href="' + thumbAlbumUrl + '" target="_blank" rel="noopener noreferrer" class="jzsa-gallery-thumb-link-btn swiper-button-external-link" tabindex="0" aria-label="' + jzsaEscapeAttr(jzsaI18n('openAlbumGooglePhotos')) + '"></a>';
7712                }
7713                if (showThumbDownload) {
7714                    thumbOverlayBtns += '<div class="jzsa-gallery-thumb-download-btn swiper-button-download" role="button" tabindex="0" data-index="' + item.index + '" data-media-type="' + mediaLabel + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('downloadMedia', item.index + 1)) + '"></div>';
7715                }
7716                if (allowThumbFullscreen) {
7717                    if (thumbDualExpand) {
7718                        thumbOverlayBtns += '<div class="jzsa-gallery-thumb-fs-btn swiper-button-lightbox" role="button" tabindex="0" data-index="' + item.index + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('openMediaLightbox', item.index + 1)) + '"></div>';
7719                        thumbOverlayBtns += '<div class="jzsa-gallery-thumb-fs-btn swiper-button-fullscreen" role="button" tabindex="0" data-index="' + item.index + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n('openMediaFullscreen', item.index + 1)) + '"></div>';
7720                    } else {
7721                        var singleExpandClass = thumbLightboxOn ? 'swiper-button-lightbox' : 'swiper-button-fullscreen';
7722                        thumbOverlayBtns += '<div class="jzsa-gallery-thumb-fs-btn ' + singleExpandClass + '" role="button" tabindex="0" data-index="' + item.index + '" aria-label="' + jzsaEscapeAttr(jzsaFormatI18n(thumbExpandI18nKey, item.index + 1)) + '"></div>';
7723                    }
7724                }
7725
7726                // Info box overlays for justified thumbnails.
7727                var justifiedZoneFormats = readInfoZoneFormats($container);
7728                var justifiedZoneHtml = buildInfoZoneHtml(item.photo, justifiedZoneFormats.inline, justifiedZoneFormats.fullscreen, {
7729                    item: String(item.index + 1),
7730                    items: String(parseInt($container.attr('data-total-count'), 10) || 0),
7731                    albumTitle: $container.attr('data-album-title') || ''
7732                });
7733
7734                html +=
7735                    '<div class="' + itemClass + '" data-index="' + item.index + '" style="width:' + width + 'px;height:' + targetHeight + 'px;">' +
7736                        mediaHtml +
7737                        justifiedZoneHtml +
7738                        thumbOverlayBtns +
7739                    '</div>';
7740            });
7741            html += '</div>';
7742        });
7743
7744        renderGalleryMarkup($container, html);
7745    }
7746
7747    /**
7748     * Determine the active grid-gallery column count for the current viewport.
7749     *
7750     * @param {jQuery} $container Gallery album element.
7751     * @return {number}
7752     */
7753    function getUniformColumnsForViewport($container) {
7754        var columnsDesktop = parseInt(readGalleryAttr($container, 'columns'), 10) || 3;
7755        var columnsTablet  = parseInt(readGalleryAttr($container, 'columns-tablet'), 10) || 2;
7756        var columnsMobile  = parseInt(readGalleryAttr($container, 'columns-mobile'), 10) || 1;
7757        var viewportWidth  = window.innerWidth || document.documentElement.clientWidth || 1024;
7758
7759        if (viewportWidth <= 480) {
7760            return columnsMobile;
7761        }
7762
7763        if (viewportWidth <= 768) {
7764            return columnsTablet;
7765        }
7766
7767        return columnsDesktop;
7768    }
7769
7770    /**
7771     * Build a page slice of photos for the gallery renderer.
7772     *
7773     * @param {Array}  allPhotos Full ordered photo array.
7774     * @param {number} pageIndex Zero-based page index.
7775     * @param {number} pageSize  Photos per page.
7776     * @return {Array} Array of objects: { photo, index }.
7777     */
7778    function getGalleryPageItems(allPhotos, pageIndex, pageSize) {
7779        var items = [];
7780        var safePageSize = pageSize > 0 ? pageSize : (allPhotos.length || 1);
7781        var start = pageIndex * safePageSize;
7782        var end = Math.min(start + safePageSize, allPhotos.length);
7783
7784        for (var i = start; i < end; i++) {
7785            items.push({
7786                photo: allPhotos[i],
7787                index: i
7788            });
7789        }
7790
7791        return items;
7792    }
7793
7794    /**
7795     * Ensure the gallery container is wrapped by a positioned shell for overlay controls.
7796     *
7797     * @param {jQuery} $container Gallery album element.
7798     * @return {jQuery} Shell element.
7799     */
7800    function ensureGalleryShell($container) {
7801        var $shell = $container.parent('.jzsa-gallery-shell');
7802
7803        if (!$shell.length) {
7804            $container.wrap('<div class="jzsa-gallery-shell"></div>');
7805            $shell = $container.parent('.jzsa-gallery-shell');
7806        }
7807
7808        // Keep shell dimensions aligned with explicit shortcode size so
7809        // overlaid gallery controls (prev/next/counter) stay within the same box.
7810        // Use renderer-provided explicit attributes to avoid style drift when
7811        // JS temporarily sets container width/height to percentages.
7812        var explicitWidthPx = parseInt(readGalleryAttr($container, 'explicit-width'), 10);
7813        var explicitHeightPx = parseInt(readGalleryAttr($container, 'explicit-height'), 10);
7814
7815        var hasExplicitWidth  = !isNaN(explicitWidthPx) && explicitWidthPx > 0;
7816        var hasExplicitHeight = !isNaN(explicitHeightPx) && explicitHeightPx > 0;
7817
7818        var explicitWidth = hasExplicitWidth
7819            ? explicitWidthPx + 'px'
7820            : (($container[0] && $container[0].style) ? $container[0].style.width : '');
7821        var explicitHeight = hasExplicitHeight
7822            ? explicitHeightPx + 'px'
7823            : (($container[0] && $container[0].style) ? $container[0].style.height : '');
7824
7825        $shell.css('width', explicitWidth || '');
7826        // Allow the shell to shrink on narrow screens when a width was explicitly set.
7827        $shell.css('max-width', explicitWidth ? '100%' : '');
7828
7829        // When both dimensions are explicit, swap fixed height for aspect-ratio on
7830        // modern browsers so the height scales proportionally as the width shrinks.
7831        // Fall back to the original fixed-height behaviour on browsers that don't
7832        // support aspect-ratio (mainly iOS ≤ 14).
7833        if (hasExplicitWidth && hasExplicitHeight &&
7834                typeof CSS !== 'undefined' && CSS.supports && CSS.supports('aspect-ratio', '1')) {
7835            $shell.css('height', '');
7836            $shell.css('aspect-ratio', explicitWidthPx + ' / ' + explicitHeightPx);
7837        } else {
7838            $shell.css('aspect-ratio', '');
7839            $shell.css('height', explicitHeight || '');
7840        }
7841
7842        $shell.toggleClass('jzsa-gallery-shell-bounded', !!(explicitWidth || explicitHeight));
7843
7844        // When shell is explicitly bounded, keep gallery content inside that box.
7845        if (explicitWidth) {
7846            $container.css('width', '100%');
7847        } else {
7848            $container.css('width', '');
7849        }
7850        if (explicitHeight) {
7851            $container.css('height', '100%');
7852        } else {
7853            $container.css('height', '');
7854        }
7855
7856        return $shell;
7857    }
7858
7859    /**
7860     * Build/update gallery pagination controls using existing Swiper-style controls.
7861     *
7862     * @param {jQuery}   $container    Gallery album element.
7863     * @param {Object}   state         Pagination state object.
7864     * @param {Function}
7864 onPageChange  Callback invoked with next page index.
7865     * @param {Object}   options       Optional controls config.
7866     */
7867    function setupGalleryPaginationControls($container, state, onPageChange, options) {
7868        var config = options || {};
7869        if ($container.attr('data-interaction-lock') === 'true') {
7870            removeGalleryPaginationControls($container);
7871            return;
7872        }
7873
7874        var showCounter = config.showCounter !== false;
7875        var showAutoplayProgress = !!config.showAutoplayProgress;
7876        var showSlideshowControls = !!config.showSlideshowControls;
7877        var isAutoplayRunning = typeof config.isAutoplayRunning === 'function' ? config.isAutoplayRunning : null;
7878        var onToggleAutoplay = typeof config.onToggleAutoplay === 'function' ? config.onToggleAutoplay : null;
7879        var controlsId = $container.attr('id') + '-gallery-controls';
7880        var $shell = ensureGalleryShell($container);
7881        var $controls = $('#' + controlsId);
7882        var infoHaloEffect = readBooleanDataAttr($container, 'data-info-halo-effect', true);
7883        var pageBottomFmt = $container.attr('data-gallery-info-bottom');
7884        if (pageBottomFmt === undefined) {
7885            pageBottomFmt = $container.attr('data-gallery-page-bottom');
7886        }
7887        var showSinglePageInfo = state.totalPages <= 1 && config.showSinglePageInfo === true &&
7888            typeof pageBottomFmt === 'string' && pageBottomFmt !== '';
7889
7890        if (state.totalPages <= 1 && !showSinglePageInfo) {
7891            $controls.remove();
7892            return;
7893        }
7894
7895        if (!$controls.length) {
7896            var html =
7897                '<div id="' + controlsId + '" class="jzsa-gallery-controls jzsa-album" data-info-halo-effect="' + (infoHaloEffect ? 'true' : 'false') + '" role="group" aria-label="Gallery page navigation">' +
7898                    '<div class="swiper-button-prev" role="button" tabindex="0" aria-label="' + jzsaEscapeAttr(jzsaI18n('previousGalleryPage')) + '"></div>' +
7899                    '<div class="swiper-pagination" aria-live="polite"></div>' +
7900                    '<button class="swiper-button-play-pause" title="' + jzsaEscapeAttr(jzsaI18n('playPause')) + '" aria-label="' + jzsaEscapeAttr(jzsaI18n('pauseSlideshow')) + '"></button>' +
7901                    '<div class="swiper-slideshow-progress" aria-hidden="true"><div class="swiper-slideshow-progress-bar"></div></div>' +
7902                    '<div class="swiper-button-next" role="button" tabindex="0" aria-label="' + jzsaEscapeAttr(jzsaI18n('nextGalleryPage')) + '"></div>' +
7903                '</div>';
7904
7905            $shell.append(html);
7906            $controls = $('#' + controlsId);
7907        }
7908
7909        $controls.attr('data-info-halo-effect', infoHaloEffect ? 'true' : 'false');
7910		$controls.toggleClass('jzsa-gallery-info-only', showSinglePageInfo);
7911        [
7912            'data-info-top-halo-effect',
7913            'data-info-top-secondary-halo-effect',
7914            'data-info-bottom-halo-effect',
7915            'data-gallery-info-bottom-halo-effect',
7916            'data-album-title-halo-effect'
7917        ].forEach(function(attrName) {
7918            var haloAttrValue = $container.attr(attrName);
7919            if (haloAttrValue !== undefined) {
7920                $controls.attr(attrName, haloAttrValue);
7921            } else {
7922                $controls.removeAttr(attrName);
7923            }
7924        });
7925
7926        var $prev = $controls.find('.swiper-button-prev');
7927        var $next = $controls.find('.swiper-button-next');
7928        var $status = $controls.find('.swiper-pagination');
7929        var $playPause = $controls.find('.swiper-button-play-pause');
7930        var $progressContainer = $controls.find('.swiper-slideshow-progress');
7931        var $progressBar = $controls.find('.swiper-slideshow-progress-bar');
7932        var controlsColor = $container.attr('data-controls-color');
7933        if (controlsColor) {
7934            $controls[0].style.setProperty('--jzsa-controls-color', controlsColor);
7935            applyControlsColorToIcons('#' + $container.attr('id'), controlsColor);
7936        }
7937        var infoFontSize = getInfoFontSizePx($container, 'data-info-font-size', 12);
7938        $controls[0].style.setProperty('--jzsa-info-font-size', infoFontSize + 'px');
7939        var infoFontFamily = getInfoFontFamily($container, 'data-info-font-family', '');
7940        if (infoFontFamily) {
7941            $controls[0].style.setProperty('--jzsa-info-font-family', infoFontFamily);
7942        } else {
7943            $controls[0].style.removeProperty('--jzsa-info-font-family');
7944        }
7945        var infoFontColor = getInfoFontColor($container, 'data-info-font-color', '');
7946        if (infoFontColor) {
7947            $controls[0].style.setProperty('--jzsa-info-font-color', infoFontColor);
7948        } else {
7949            $controls[0].style.removeProperty('--jzsa-info-font-color');
7950        }
7951
7952        function isActivationKey(e) {
7953            return e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32;
7954        }
7955
7956        function bindControl($el, direction) {
7957            function activate(e) {
7958                if (e.type === 'keydown' && !isActivationKey(e)) {
7959                    return;
7960                }
7961                e.preventDefault();
7962                if ($container.data('jzsaGalleryAnimating')) {
7963                    return;
7964                }
7965
7966                var nextPage = state.currentPage + direction;
7967                if (nextPage < 0) {
7968                    nextPage = state.totalPages - 1;
7969                } else if (nextPage > state.totalPages - 1) {
7970                    nextPage = 0;
7971                }
7972
7973                onPageChange(nextPage, direction);
7974            }
7975
7976            $el.off('click.jzsaGallery keydown.jzsaGallery');
7977            $el.on('click.jzsaGallery', activate);
7978            $el.on('keydown.jzsaGallery', activate);
7979        }
7980
7981        bindControl($prev, -1);
7982        bindControl($next, 1);
7983
7984        $controls.toggleClass('jzsa-gallery-slideshow-enabled', showSlideshowControls);
7985        if (showSlideshowControls) {
7986            var running = isAutoplayRunning ? isAutoplayRunning() : false;
7987            $playPause.toggleClass('playing', !!running);
7988            $playPause.attr('aria-label', running ? jzsaI18n('pauseSlideshow') : jzsaI18n('resumeSlideshow'));
7989            $playPause.attr('title', running ? jzsaI18n('pauseSlideshow') : jzsaI18n('resumeSlideshow'));
7990
7991            $playPause.off('click.jzsaGallery keydown.jzsaGallery');
7992            $playPause.on('click.jzsaGallery keydown.jzsaGallery', function(e) {
7993                if (e.type === 'keydown' && !isActivationKey(e)) {
7994                    return;
7995                }
7996                e.preventDefault();
7997                e.stopPropagation();
7998                if (onToggleAutoplay) {
7999                    onToggleAutoplay();
8000                }
8001            });
8002        } else {
8003            $playPause.off('click.jzsaGallery keydown.jzsaGallery');
8004            $playPause.removeClass('playing');
8005        }
8006
8007	        if (showCounter && typeof pageBottomFmt === 'string') {
8008            // gallery-info-bottom is the final parser-provided format string (with legacy fallback to gallery-page-bottom).
8009            var pageText = resolveInfoPlaceholders(pageBottomFmt, {}, {
8010                page: String(state.currentPage + 1),
8011                pages: String(state.totalPages),
8012                albumTitle: $container.attr('data-album-title') || ''
8013            });
8014            $status.text(pageText).toggle(pageText !== '');
8015        } else {
8016            $status.hide();
8017        }
8018        $prev.removeClass('swiper-button-disabled').attr('aria-disabled', 'false');
8019        $next.removeClass('swiper-button-disabled').attr('aria-disabled', 'false');
8020        $progressBar.css({
8021            transform: 'scaleX(1)',
8022            transition: 'none'
8023        });
8024        $progressContainer.css('display', showAutoplayProgress ? 'block' : 'none');
8025    }
8026
8027    /**
8028     * Remove gallery pagination controls if they are present.
8029     *
8030     * @param {jQuery} $container Gallery album element.
8031     */
8032    function removeGalleryPaginationControls($container) {
8033        $('#' + $container.attr('id') + '-gallery-controls').remove();
8034    }
8035
8036    /**
8037     * Enable or disable fixed-height gallery scrolling.
8038     *
8039     * @param {jQuery} $container Gallery album element.
8040     * @param {boolean} enabled   Whether scrolling mode is enabled.
8041     * @param {number} maxHeight  Max visible height in pixels.
8042     */
8043    function setGalleryScrollableState($container, enabled, maxHeight) {
8044        if (!enabled) {
8045            $container.removeClass('jzsa-gallery-scrollable');
8046            $container.css('max-height', '');
8047            removeGalleryScrollHint($container);
8048            return;
8049        }
8050
8051        $container.addClass('jzsa-gallery-scrollable');
8052
8053        if (typeof maxHeight === 'number' && maxHeight > 0) {
8054            $container.css('max-height', Math.floor(maxHeight) + 'px');
8055        } else {
8056            $container.css('max-height', '');
8057        }
8058
8059        createGalleryScrollHint($container);
8060    }
8061
8062    function createGalleryScrollHint($container) {
8063        var $shell = $container.closest('.jzsa-gallery-shell');
8064        if (!$shell.length) {
8065            return;
8066        }
8067
8068        removeGalleryScrollHint($container);
8069
8070        var label = jzsaI18n('scrollForMore') || 'Scroll for more';
8071        var $label = $('<span class="jzsa-scroll-hint-label"></span>').text(label);
8072        var $icon  = $('<span class="jzsa-scroll-hint-icon" aria-hidden="true"></span>').html(
8073            '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 24" fill="none"' +
8074            ' stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
8075            '<path d="M5 10 L5 3 M3 6 L5 3 L7 6"/>' +
8076            '<path d="M5 14 L5 21 M3 18 L5 21 L7 18"/>' +
8077            '<rect x="13" y="3" width="7" height="18" rx="3.5"/>' +
8078            '<circle cx="16.5" cy="7" r="1.5" fill="white" stroke="none"/>' +
8079            '</svg>'
8080        );
8081        var $hint  = $('<div class="jzsa-gallery-scroll-hint" aria-hidden="true"></div>')
8082            .append($label)
8083            .append($icon);
8084        $shell.append($hint);
8085
8086        // Double rAF ensures the browser has painted opacity:0 before
8087        // the transition to opacity:1 starts, giving a clean fade-in.
8088        requestAnimationFrame(function() {
8089            requestAnimationFrame(function() {
8090                $hint.addClass('jzsa-gallery-scroll-hint--visible');
8091            });
8092        });
8093
8094        function fadeOutHint() {
8095            clearTimeout(autoHideTimer);
8096            $container.off('scroll.jzsaScrollHint');
8097            $hint.removeClass('jzsa-gallery-scroll-hint--visible');
8098            setTimeout(function() { $hint.remove(); }, 850);
8099        }
8100
8101        var autoHideTimer = setTimeout(fadeOutHint, 3000);
8102        $hint.data('jzsa-auto-hide', autoHideTimer);
8103
8104        $container.one('scroll.jzsaScrollHint', fadeOutHint);
8105    }
8106
8107    function removeGalleryScrollHint($container) {
8108        var $shell = $container.closest('.jzsa-gallery-shell');
8109        var $existing = $shell.find('.jzsa-gallery-scroll-hint');
8110        clearTimeout($existing.data('jzsa-auto-hide'));
8111        $existing.remove();
8112        $container.off('scroll.jzsaScrollHint');
8113    }
8114
8115    /**
8116     * Keep paginated gallery height stable across pages to avoid layout jumps.
8117     *
8118     * @param {jQuery} $container Gallery album element.
8119     * @param {boolean} enabled   Whether fixed-height pagination is enabled.
8120     * @param {number} height     Target minimum height in pixels.
8121     */
8122    function setGalleryPaginationHeightState($container, enabled, height) {
8123        if (!enabled || typeof height !== 'number' || height <= 0) {
8124            $container.css('min-height', '');
8125            return;
8126        }
8127
8128        $container.css('min-height', Math.ceil(height) + 'px');
8129    }
8130
8131    /**
8132     * Build a stable visual snapshot of the current gallery markup for transitions.
8133     *
8134     * @param {jQuery} $source Gallery album element.
8135     * @return {jQuery} Snapshot node.
8136     */
8137    function createGalleryTransitionSnapshot($source) {
8138        var $snapshot = $source.clone(false, false);
8139        var isCssGallery = false;
8140        $snapshot.removeAttr('id');
8141        $snapshot.removeClass('jzsa-gallery-scrollable jzsa-gallery-transition-target');
8142        $snapshot.css({
8143            maxHeight: '',
8144            overflowY: '',
8145            overflowX: '',
8146            visibility: '',
8147            transform: '',
8148            transition: '',
8149            scrollbarGutter: ''
8150        });
8151
8152        var sourceRect = $source[0] ? $source[0].getBoundingClientRect() : null;
8153        if (sourceRect && sourceRect.width > 0) {
8154            $snapshot.css('width', sourceRect.width + 'px');
8155        }
8156
8157        // Freeze computed layout to prevent intermediate one-tile flashes.
8158        if ($source[0] && window.getComputedStyle) {
8159            var sourceStyle = window.getComputedStyle($source[0]);
8160            if (sourceStyle && sourceStyle.display === 'grid') {
8161                isCssGallery = true;
8162                $snapshot.css({
8163                    display: 'grid',
8164                    gridTemplateColumns: sourceStyle.gridTemplateColumns,
8165                    gridAutoRows: sourceStyle.gridAutoRows,
8166                    gap: sourceStyle.gap
8167                });
8168            }
8169        }
8170
8171        var sourceThumbs = $source.find('.jzsa-gallery-thumb');
8172        var snapshotThumbs = $snapshot.find('.jzsa-gallery-thumb');
8173        snapshotThumbs.each(function(i) {
8174            var sourceThumb = sourceThumbs.get(i);
8175            if (!sourceThumb) {
8176                return;
8177            }
8178
8179            var rect = sourceThumb.getBoundingClientRect();
8180            if (!isCssGallery && rect.width > 0 && rect.height > 0) {
8181                this.style.width = rect.width + 'px';
8182                this.style.height = rect.height + 'px';
8183                this.style.aspectRatio = '';
8184            }
8185
8186            this.setAttribute('loading', 'eager');
8187            this.setAttribute('decoding', 'sync');
8188        });
8189
8190        return $snapshot;
8191    }
8192
8193    /**
8194     * Pointer travel, in pixels, below which a mouse press still counts as a
8195     * click rather than a drag.
8196     */
8197    var GALLERY_DRAG_MOVE_TOLERANCE_PX = 5;
8198
8199    /**
8200     * Enable desktop mouse drag behavior for gallery pagination/scrolling.
8201     *
8202     * @param {jQuery} $container Gallery album element.
8203     * @param {Object} config     { enabled, mode, onPageSwipe, onPageDragStart }.
8204     */
8205    function setupGalleryMouseInteractions($container, config) {
8206        var id = $container.attr('id') || 'gallery';
8207        var ns = '.jzsaGalleryMouse-' + id;
8208        var $shell = ensureGalleryShell($container);
8209        var options = config || {};
8210        var mode = options.mode || '';
8211        var enabled = !!options.enabled;
8212        var onPageSwipe = typeof options.onPageSwipe === 'function' ? options.onPageSwipe : null;
8213        var onPageDragStart = typeof options.onPageDragStart === 'function' ? options.onPageDragStart : null;
8214
8215        $container.off(ns);
8216        $(document).off(ns);
8217        $container.removeClass('jzsa-gallery-draggable jzsa-gallery-grabbing');
8218        $shell.removeClass('jzsa-gallery-draggable jzsa-gallery-grabbing');
8219        $container.removeData('jzsaGallerySuppressClick');
8220
8221        if (!enabled) {
8222            return;
8223        }
8224
8225        $container.addClass('jzsa-gallery-draggable');
8226        $shell.addClass('jzsa-gallery-draggable');
8227
8228        var state = {
8229            active: false,
8230            moved: false,
8231            swipeTriggered: false,
8232            startX: 0,
8233            startY: 0,
8234            startScrollTop: 0,
8235            lastDeltaX: 0,
8236            lastDeltaY: 0,
8237            dragDirection: 0,
8238            dragSession: null
8239        };
8240
8241        function suppressNextThumbClick() {
8242            $container.data('jzsaGallerySuppressClick', true);
8243            window.setTimeout(function() {
8244                $container.removeData('jzsaGallerySuppressClick');
8245            }, 180);
8246        }
8247
8248        function stopDraggingState() {
8249            state.active = false;
8250            $container.removeClass('jzsa-gallery-grabbing');
8251            $shell.removeClass('jzsa-gallery-grabbing');
8252        }
8253
8254        function finalizePaginationDragSession() {
8255            if (mode !== 'pagination' || !state.dragSession) {
8256                return false;
8257            }
8258
8259            var threshold = Math.max(30, ($container.width() || 0) * 0.10);
8260            var hasDirectionalDelta =
8261                (state.dragDirection === 1 && state.lastDeltaX < 0) ||
8262                (state.dragDirection === -1 && state.lastDeltaX > 0);
8263            var shouldCommit = hasDirectionalDelta && Math.abs(state.lastDeltaX) >= threshold;
8264            var session = state.dragSession;
8265
8266            state.dragSession = null;
8267            state.swipeTriggered = shouldCommit;
8268
8269            if (typeof session.finish === 'function') {
8270                session.finish(shouldCommit);
8271            } else if (shouldCommit && onPageSwipe) {
8272                onPageSwipe(state.dragDirection);
8273            }
8274
8275            suppressNextThumbClick();
8276            return true;
8277        }
8278
8279        function isGalleryVideoTarget(target) {
8280            return $(target).closest('.jzsa-gallery-item-video .jzsa-video-wrapper').length > 0;
8281        }
8282
8283        $container.on('dragstart' + ns, '.jzsa-gallery-thumb', function(e) {
8284            e.preventDefault();
8285        });
8286
8287        $container.on('click' + ns, '.jzsa-gallery-thumb', function(e) {
8288            if ($container.data('jzsaGallerySuppressClick')) {
8289                e.preventDefault();
8290                e.stopImmediatePropagation();
8291            }
8292        });
8293        $container.on('click' + ns, '.jzsa-gallery-thumb-fs-btn', function(e) {
8294            if ($container.data('jzsaGallerySuppressClick')) {
8295                e.preventDefault();
8296                e.stopImmediatePropagation();
8297            }
8298        });
8299
8300        $container.on('mousedown' + ns, function(e) {
8301            if (e.which !== 1) {
8302                return;
8303            }
8304
8305            if (isGalleryVideoTarget(e.target)) {
8306                return;
8307            }
8308
8309            if ($(e.target).closest('.swiper-button-prev, .swiper-button-next, .jzsa-gallery-thumb-fs-btn').length) {
8310                return;
8311            }
8312
8313            state.active = true;
8314            state.moved = false;
8315            state.swipeTriggered = false;
8316            state.startX = e.pageX;
8317            state.startY = e.pageY;
8318            state.startScrollTop = $container.scrollTop();
8319            state.lastDeltaX = 0;
8320            state.lastDeltaY = 0;
8321            state.dragDirection = 0;
8322            state.dragSession = null;
8323
8324            $container.addClass('jzsa-gallery-grabbing');
8325            $shell.addClass('jzsa-gallery-grabbing');
8326            e.preventDefault();
8327        });
8328
8329        $(document).on('mousemove' + ns, function(e) {
8330            if (!state.active) {
8331                return;
8332            }
8333
8334            var deltaX = e.pageX - state.startX;
8335            var deltaY = e.pageY - state.startY;
8336            state.lastDeltaX = deltaX;
8337            state.lastDeltaY = deltaY;
8338
8339            if (
8340                Math.abs(deltaX) > GALLERY_DRAG_MOVE_TOLERANCE_PX ||
8341                Math.abs(deltaY) > GALLERY_DRAG_MOVE_TOLERANCE_PX
8342            ) {
8343                state.moved = true;
8344            }
8345
8346            if (mode === 'scroll') {
8347                $container.scrollTop(state.startScrollTop - deltaY);
8348                e.preventDefault();
8349                return;
8350            }
8351
8352            if (mode === 'pagination') {
8353                var isHorizontalGesture = Math.abs(deltaX) > Math.abs(deltaY);
8354
8355                if (onPageDragStart) {
8356                    if (!state.dragSession && !state.swipeTriggered && isHorizontalGesture && Math.abs(deltaX) >= 6) {
8357                        state.dragDirection = deltaX < 0 ? 1 : -1;
8358                        state.dragSession = onPageDragStart(state.dragDirection);
8359                        if (state.dragSession) {
8360                            suppressNextThumbClick();
8361                        }
8362                    }
8363
8364                    if (state.dragSession && typeof state.dragSession.update === 'function') {
8365                        state.dragSession.update(deltaX);
8366                        e.preventDefault();
8367                        return;
8368                    }
8369                }
8370
8371                if (!onPageDragStart && !state.swipeTriggered) {
8372                    var threshold = Math.max(30, ($container.width() || 0) * 0.10);
8373                    if (isHorizontalGesture && Math.abs(deltaX) >= threshold) {
8374                        state.swipeTriggered = true;
8375                        suppressNextThumbClick();
8376                        if (onPageSwipe) {
8377                            onPageSwipe(deltaX < 0 ? 1 : -1);
8378                        }
8379                        e.preventDefault();
8380                    }
8381                } else if (isHorizontalGesture) {
8382                    e.preventDefault();
8383                }
8384            }
8385        });
8386
8387        $(document).on('mouseup' + ns, function() {
8388            if (!state.active) {
8389                return;
8390            }
8391
8392            if (finalizePaginationDragSession()) {
8393                stopDraggingState();
8394                return;
8395            }
8396
8397            if (state.moved || state.swipeTriggered) {
8398                suppressNextThumbClick();
8399            }
8400            stopDraggingState();
8401        });
8402
8403        $(document).on('mouseleave' + ns, function() {
8404            if (!state.active) {
8405                return;
8406            }
8407
8408            if (finalizePaginationDragSession()) {
8409                stopDraggingState();
8410                return;
8411            }
8412
8413            if (state.moved || state.swipeTriggered) {
8414                suppressNextThumbClick();
8415            }
8416            stopDraggingState();
8417        });
8418    }
8419
8420    /**
8421     * Create an interactive drag session for gallery page transitions.
8422     *
8423     * @param {jQuery}   $container         Gallery album element.
8424     * @param {number}   direction          +1 next page, -1 previous page.
8425     * @param {Function} renderIncomingPage Renders the target page into $container.
8426     * @param {Function} renderOutgoingPage Renders the current page into $container.
8427     * @param {Function} onFinish           Callback called with committed boolean.
8428     * @return {?Object} Session with update(deltaX) and finish(commit).
8429     */
8430    function createGalleryPageDragTransition($container, direction, renderIncomingPage, renderOutgoingPage, onFinish) {
8431        var DRAG_SETTLE_MS = 220;
8432
8433        if (
8434            !$container ||
8435            !$container.length ||
8436            typeof renderIncomingPage !== 'function' ||
8437            typeof renderOutgoingPage !== 'function'
8438        ) {
8439            return null;
8440        }
8441
8442        if ($container.data('jzsaGalleryAnimating')) {
8443            return null;
8444        }
8445
8446        var $shell = ensureGalleryShell($container);
8447        var outgoingRect = $container[0] ? $container[0].getBoundingClientRect() : null;
8448        var outgoingHeight = outgoingRect ? outgoingRect.height : $container.outerHeight();
8449        var outgoingWidth = outgoingRect ? outgoingRect.width : $container.outerWidth();
8450        if (!outgoingWidth || !outgoingHeight) {
8451            return null;
8452        }
8453
8454        var $outgoingSnapshot = createGalleryTransitionSnapshot($container);
8455
8456        $shell.addClass('jzsa-gallery-transitioning');
8457        $container.data('jzsaGalleryAnimating', true);
8458        $container.css('visibility', 'hidden');
8459
8460        renderIncomingPage();
8461        var $incomingSnapshot = createGalleryTransitionSnapshot($container);
8462
8463        var stageHeight = outgoingHeight;
8464        if (stageHeight > 0) {
8465            $shell.css({
8466                height: stageHeight + 'px',
8467                minHeight: stageHeight + 'px'
8468            });
8469        }
8470
8471        $shell.find('.jzsa-gallery-slide-stage').remove();
8472
8473        var $stage = $('<div class="jzsa-gallery-slide-stage"></div>');
8474        var $track = $('<div class="jzsa-gallery-slide-track"></div>');
8475        var $panelA = $('<div class="jzsa-gallery-slide-panel"></div>');
8476        var $panelB = $('<div class="jzsa-gallery-slide-panel"></div>');
8477        var slideDistance = outgoingWidth;
8478        if (!slideDistance || slideDistance <= 0) {
8479            var shellRect = $shell[0] ? $shell[0].getBoundingClientRect() : null;
8480            slideDistance = shellRect ? shellRect.width : ($shell.width() || 0);
8481        }
8482
8483        if (stageHeight > 0) {
8484            $stage.css('height', stageHeight + 'px');
8485            $track.css('height', stageHeight + 'px');
8486        }
8487
8488        $track.css({
8489            width: (slideDistance * 2) + 'px',
8490            transitionDuration: '0ms'
8491        });
8492        $panelA.css({
8493            width: slideDistance + 'px',
8494            maxWidth: slideDistance + 'px',
8495            flex: '0 0 ' + slideDistance + 'px'
8496        });
8497        $panelB.css({
8498            width: slideDistance + 'px',
8499            maxWidth: slideDistance + 'px',
8500            flex: '0 0 ' + slideDistance + 'px'
8501        });
8502
8503        var startTransform = direction > 0 ? 0 : -slideDistance;
8504        var endTransform = direction > 0 ? -slideDistance : 0;
8505        var currentTransform = startTransform;
8506
8507        if (direction > 0) {
8508            $panelA.append($outgoingSnapshot);
8509            $panelB.append($incomingSnapshot);
8510        } else {
8511            $panelA.append($incomingSnapshot);
8512            $panelB.append($outgoingSnapshot);
8513        }
8514
8515        $track.append($panelA, $panelB);
8516        $stage.append($track);
8517        $shell.append($stage);
8518        $track.css('transform', 'translateX(' + startTransform + 'px)');
8519
8520        var finished = false;
8521        function cleanup(committed) {
8522            if (finished) {
8523                return;
8524            }
8525            finished = true;
8526
8527            if (!committed) {
8528                renderOutgoingPage();
8529            }
8530
8531            $stage.remove();
8532            $container.css('visibility', '');
8533            $shell.removeClass('jzsa-gallery-transitioning');
8534            $shell.css({
8535                height: '',
8536                minHeight: ''
8537            });
8538            // Restore bounded shell dimensions (when explicit width/height are set).
8539            ensureGalleryShell($container);
8540            $container.data('jzsaGalleryAnimating', false);
8541
8542            if (typeof onFinish === 'function') {
8543                onFinish(committed);
8544            }
8545        }
8546
8547        return {
8548            update: function(deltaX) {
8549                if (finished) {
8550                    return;
8551                }
8552
8553                var nextTransform = startTransform + deltaX;
8554                if (nextTransform > 0) {
8555                    nextTransform = 0;
8556                } else if (nextTransform < -slideDistance) {
8557                    nextTransform = -slideDistance;
8558                }
8559
8560                currentTransform = nextTransform;
8561                $track.css({
8562                    transitionDuration: '0ms',
8563                    transform: 'translateX(' + currentTransform + 'px)'
8564                });
8565            },
8566            finish: function(committed) {
8567                if (finished) {
8568                    return;
8569                }
8570
8571                var target = committed ? endTransform : startTransform;
8572                $track.css('transition-duration', DRAG_SETTLE_MS + 'ms');
8573                runOnNextPaint(function() {
8574                    if (finished) {
8575                        return;
8576                    }
8577                    $track.css('transform', 'translateX(' + target + 'px)');
8578                });
8579
8580                waitForTransitionEnd($track, DRAG_SETTLE_MS + 60, function() {
8581                    cleanup(!!committed);
8582                });
8583            }
8584        };
8585    }
8586
8587    /**
8588     * Animate gallery page changes with a horizontal slide similar to single mode.
8589     *
8590     * @param {jQuery}  $container     Gallery album element.
8591     * @param {number}  direction      +1 next page, -1 previous page.
8592     * @param {Function} renderNewPage Callback that renders the next page into $container.
8593     */
8594    function animateGalleryPageTransition($container, direction, renderNewPage) {
8595        var GALLERY_PAGE_TRANSITION_MS = 600;
8596
8597        if (!$container || !$container.length || typeof renderNewPage !== 'function') {
8598            return;
8599        }
8600
8601        if ($container.data('jzsaGalleryAnimating')) {
8602            renderNewPage();
8603            return;
8604        }
8605
8606        var $shell = ensureGalleryShell($container);
8607        var outgoingRect = $container[0] ? $container[0].getBoundingClientRect() : null;
8608        var outgoingHeight = outgoingRect ? outgoingRect.height : $container.outerHeight();
8609        var outgoingWidth = outgoingRect ? outgoingRect.width : $container.outerWidth();
8610
8611        if (!outgoingWidth || !outgoingHeight) {
8612            renderNewPage();
8613            return;
8614        }
8615
8616        var $outgoingSnapshot = createGalleryTransitionSnapshot($container);
8617
8618        $shell.addClass('jzsa-gallery-transitioning');
8619        $container.data('jzsaGalleryAnimating', true);
8620        $container.css('visibility', 'hidden');
8621
8622        renderNewPage();
8623
8624        var $incomingSnapshot = createGalleryTransitionSnapshot($container);
8625
8626        // Keep shell height fixed during transition to avoid temporary layout shifts.
8627        var stageHeight = outgoingHeight;
8628        if (stageHeight > 0) {
8629            $shell.css({
8630                height: stageHeight + 'px',
8631                minHeight: stageHeight + 'px'
8632            });
8633        }
8634
8635        $shell.find('.jzsa-gallery-slide-stage').remove();
8636
8637        var $stage = $('<div class="jzsa-gallery-slide-stage"></div>');
8638        var $track = $('<div class="jzsa-gallery-slide-track"></div>');
8639        var $panelA = $('<div class="jzsa-gallery-slide-panel"></div>');
8640        var $panelB = $('<div class="jzsa-gallery-slide-panel"></div>');
8641        var slideDistance = outgoingWidth;
8642
8643        if (stageHeight > 0) {
8644            $stage.css('height', stageHeight + 'px');
8645            $track.css('height', stageHeight + 'px');
8646        }
8647        if (!slideDistance || slideDistance <= 0) {
8648            var shellRect = $shell[0] ? $shell[0].getBoundingClientRect() : null;
8649            slideDistance = shellRect ? shellRect.width : ($shell.width() || 0);
8650        }
8651        $track.css({
8652            width: (slideDistance * 2) + 'px',
8653            transitionDuration: GALLERY_PAGE_TRANSITION_MS + 'ms'
8654        });
8655        $panelA.css({
8656            width: slideDistance + 'px',
8657            maxWidth: slideDistance + 'px',
8658            flex: '0 0 ' + slideDistance + 'px'
8659        });
8660        $panelB.css({
8661            width: slideDistance + 'px',
8662            maxWidth: slideDistance + 'px',
8663            flex: '0 0 ' + slideDistance + 'px'
8664        });
8665
8666        if (direction > 0) {
8667            // Next page: current content moves left, next content comes from right.
8668            $panelA.append($outgoingSnapshot);
8669            $panelB.append($incomingSnapshot);
8670            $track.css('transform', 'translateX(0px)');
8671        } else {
8672            // Previous page: current content moves right, previous content comes from left.
8673            $panelA.append($incomingSnapshot);
8674            $panelB.append($outgoingSnapshot);
8675            $track.css('transform', 'translateX(' + (-slideDistance) + 'px)');
8676        }
8677
8678        $track.append($panelA, $panelB);
8679        $stage.append($track);
8680        $shell.append($stage);
8681
8682        runOnNextPaint(function() {
8683            if (direction > 0) {
8684                $track.css('transform', 'translateX(' + (-slideDistance) + 'px)');
8685            } else {
8686                $track.css('transform', 'translateX(0px)');
8687            }
8688        });
8689
8690        waitForTransitionEnd($track, GALLERY_PAGE_TRANSITION_MS + 80, function() {
8691            $stage.remove();
8692            $container.css('visibility', '');
8693            $shell.removeClass('jzsa-gallery-transitioning');
8694            $shell.css({
8695                height: '',
8696                minHeight: ''
8697            });
8698            // Restore bounded shell dimensions (when explicit width/height are set).
8699            ensureGalleryShell($container);
8700            $container.data('jzsaGalleryAnimating', false);
8701        });
8702    }
8703
8704    /**
8705     * Build justified rows for all photos, preserving global photo indices.
8706     *
8707     * @param {jQuery} $container Gallery album element.
8708     * @param {Array}
8708  allPhotos  Full photo array.
8709     * @return {Object} { rows, targetHeight, gap, containerWidth }.
8710     */
8711    function getJustifiedLayoutData($container, allPhotos) {
8712        var targetHeight = parseInt(readGalleryAttr($container, 'row-height'), 10) || 200;
8713        var gap = parseInt(readGalleryAttr($container, 'gap'), 10) || 4;
8714        var containerWidth = getGalleryContainerWidth($container);
8715
8716        var photosWithRatios = allPhotos.map(function(photo, index) {
8717            return {
8718                photo: photo,
8719                ratio: parseAspectRatio(photo.preview || photo.full),
8720                index: index
8721            };
8722        });
8723
8724        return {
8725            rows: buildJustifiedRows(photosWithRatios, containerWidth, targetHeight, gap),
8726            targetHeight: targetHeight,
8727            gap: gap,
8728            containerWidth: containerWidth
8729        };
8730    }
8731
8732    /**
8733     * Clamp the page index so it always stays within valid bounds.
8734     *
8735     * @param {Object} state Pagination state object.
8736     */
8737    function clampGalleryPage(state) {
8738        if (state.currentPage < 0) {
8739            state.currentPage = 0;
8740        }
8741        if (state.currentPage > state.totalPages - 1) {
8742            state.currentPage = state.totalPages - 1;
8743        }
8744        if (state.currentPage < 0) {
8745            state.currentPage = 0;
8746        }
8747    }
8748
8749    /**
8750     * Initialize a gallery-mode thumbnail gallery.
8751     * Renders thumbnails and attaches click-to-fullscreen handler.
8752     *
8753     * @param {Element} container The .jzsa-gallery-album DOM element.
8754     */
8755    function initializeGallery(container) {
8756        var $container = $(container);
8757        ensureGalleryId($container);
8758        if (isGalleryInitialized($container)) {
8759            return;
8760        }
8761        markGalleryInitialized($container);
8762        var $shell = ensureGalleryShell($container);
8763        var layout     = readGalleryAttr($container, 'layout') || 'grid';
8764
8765        var allPhotosJson = $container.attr('data-all-photos');
8766        var allPhotos     = allPhotosJson ? JSON.parse(allPhotosJson) : [];
8767
8768        // Strip intro-fade so the container is visible (dashed outline) from the start.
8769        // Show the spinner immediately, but delay explanatory text so fast loads
8770        // stay visually quiet.
8771        $container
8772            .addClass('jzsa-gallery-album')
8773            .removeClass('jzsa-loaded jzsa-content-intro jzsa-content-intro-visible')
8774            .addClass('jzsa-loader-pending jzsa-gallery-loading');
8775        activateLoaderState($container, 'Loading content...', 'This is taking longer than usual...');
8776
8777        var requestedGalleryRows = parseInt(readGalleryAttr($container, 'rows'), 10);
8778        var galleryRows = (!isNaN(requestedGalleryRows) && requestedGalleryRows > 0) ? requestedGalleryRows : 0;
8779        var galleryScrollable = readGalleryAttr($container, 'scrollable') === 'true';
8780        var requestedGallerySizingModel = (readGalleryAttr($container, 'sizing') || 'ratio').toLowerCase();
8781        var gallerySizing = requestedGallerySizingModel === 'fill' ? 'fill' : 'ratio';
8782        var interactionLock = $container.attr('data-interaction-lock') === 'true';
8783        var galleryDownloadWarningSizeBytes = getDownloadWarningSizeBytes($container);
8784        var gallerySlideshowMode = $container.attr('data-slideshow') || 'disabled';
8785        var gallerySlideshowEnabled = gallerySlideshowMode !== 'disabled';
8786        var requestedGallerySlideshowDelay = parseInt($container.attr('data-slideshow-delay'), 10);
8787        var gallerySlideshowDelay = (!isNaN(requestedGallerySlideshowDelay) && requestedGallerySlideshowDelay > 0)
8788            ? requestedGallerySlideshowDelay
8789            : 5;
8790
8791        // Normalize for CSS selectors and downstream logic.
8792        writeGalleryAttr($container, 'sizing', gallerySizing);
8793
8794        // Update data so the fullscreen slideshow gets the same capped photo list.
8795        $container.attr('data-all-photos', JSON.stringify(allPhotos));
8796
8797        var paginationState = {
8798            currentPage: 0,
8799            totalPages: 1
8800        };
8801        var GALLERY_AUTOPLAY_RETRY_MS = 120;
8802        var GALLERY_AUTOPLAY_PROGRESS_EXTRA_MS = 500;
8803        var gallerySlideshowTimer = null;
8804        var galleryProgressCycleToken = 0;
8805        var gallerySlideshowPausedByHover = false;
8806        var gallerySlideshowPausedByUser = gallerySlideshowMode === 'manual';
8807        var slideshowId = null;
8808        var $slideshow = null;
8809        var galleryLoaderShownAt = Date.now();
8810        var galleryHasMarkedLoaded = false;
8811        var galleryLoaderCommitTimer = null;
8812        var galleryLoaderFallbackTimer = window.setTimeout(function() {
8813            markGalleryLoaded();
8814        }, 3500);
8815
8816        function clearGalleryLoaderTimers() {
8817            if (galleryLoaderFallbackTimer) {
8818                window.clearTimeout(galleryLoaderFallbackTimer);
8819                galleryLoaderFallbackTimer = null;
8820            }
8821            if (galleryLoaderCommitTimer) {
8822                window.clearTimeout(galleryLoaderCommitTimer);
8823                galleryLoaderCommitTimer = null;
8824            }
8825        }
8826
8827        function commitGalleryLoaded() {
8828            clearGalleryLoaderTimers();
8829            resetLoaderState($container);
8830            $container.find('.jzsa-gallery-thumb').off('.jzsaGalleryLoader');
8831            $container
8832                .removeClass('jzsa-loader-pending jzsa-gallery-loading')
8833                .addClass('jzsa-loaded');
8834        }
8835
8836        function markGalleryLoaded() {
8837            if (galleryHasMarkedLoaded) {
8838                return;
8839            }
8840
8841            galleryHasMarkedLoaded = true;
8842            var minVisibleRemaining = 0;
8843            if (galleryLoaderShownAt > 0) {
8844                minVisibleRemaining = LOADER_MIN_VISIBLE_MS - (Date.now() - galleryLoaderShownAt);
8845            }
8846
8847            if (minVisibleRemaining > 0) {
8848                galleryLoaderCommitTimer = window.setTimeout(commitGalleryLoaded, minVisibleRemaining);
8849            } else {
8850                commitGalleryLoaded();
8851            }
8852        }
8853
8854        function watchGalleryInitialThumbLoad() {
8855            if (galleryHasMarkedLoaded) {
8856                return;
8857            }
8858
8859            var $firstThumb = $container.find('.jzsa-gallery-thumb').first();
8860            if (!$firstThumb.length) {
8861                window.setTimeout(markGalleryLoaded, 700);
8862                return;
8863            }
8864
8865            var thumbEl = $firstThumb[0];
8866            $container.find('.jzsa-gallery-thumb').off('.jzsaGalleryLoader');
8867            if (thumbEl.complete && thumbEl.naturalWidth > 0) {
8868                // Image already in browser cache: wait for next paint so the
8869                // photo is actually on screen before the loader disappears.
8870                runOnNextPaint(markGalleryLoaded);
8871                return;
8872            }
8873
8874            $firstThumb.one('load.jzsaGalleryLoader', function() {
8875                // Give the browser two frames to decode and commit the image
8876                // to the display before hiding the loader, avoiding a blank flash.
8877                runOnNextPaint(markGalleryLoaded);
8878            });
8879            $firstThumb.one('error.jzsaGalleryLoader', function() {
8880                window.setTimeout(markGalleryLoaded, 500);
8881            });
8882        }
8883
8884        function clearGalleryAutoplayTimer() {
8885            if (gallerySlideshowTimer) {
8886                window.clearTimeout(gallerySlideshowTimer);
8887                gallerySlideshowTimer = null;
8888            }
8889        }
8890
8891        function shouldShowGallerySlideshowProgress() {
8892            var useScroller = galleryScrollable && galleryRows > 0;
8893            return gallerySlideshowEnabled && !gallerySlideshowPausedByUser && !useScroller && paginationState.totalPages > 1;
8894        }
8895
8896        function canRunGalleryAutoplay() {
8897            if (!shouldShowGallerySlideshowProgress() || gallerySlideshowPausedByHover) {
8898                return false;
8899            }
8900
8901            if ($slideshow && $slideshow.length && isFullscreen($slideshow[0])) {
8902                return false;
8903            }
8904
8905            return true;
8906        }
8907
8908        function setGalleryAutoplayProgressVisible(visible) {
8909            var controlsId = $container.attr('id') + '-gallery-controls';
8910            var $controls = $('#' + controlsId);
8911            var $progressContainer = $controls.find('.swiper-slideshow-progress');
8912            var $progressBar = $controls.find('.swiper-slideshow-progress-bar');
8913
8914            if (!$progressContainer.length || !$progressBar.length) {
8915                return;
8916            }
8917            galleryProgressCycleToken++;
8918
8919            $progressBar.css({
8920                transform: 'scaleX(1)',
8921                transition: 'none'
8922            });
8923            $progressContainer.css('display', visible ? 'block' : 'none');
8924        }
8925
8926        function startGalleryAutoplayProgressCycle() {
8927            var controlsId = $container.attr('id') + '-gallery-controls';
8928            var $controls = $('#' + controlsId);
8929            var $progressContainer = $controls.find('.swiper-slideshow-progress');
8930            var $progressBar = $controls.find('.swiper-slideshow-progress-bar');
8931            var delayMs = gallerySlideshowDelay * MILLISECONDS_PER_SECOND;
8932
8933            if (!$progressContainer.length || !$progressBar.length || delayMs <= 0) {
8934                return;
8935            }
8936
8937            var progressDuration = delayMs + GALLERY_AUTOPLAY_PROGRESS_EXTRA_MS;
8938            if (progressDuration < 0) {
8939                progressDuration = delayMs;
8940            }
8941            var cycleToken = ++galleryProgressCycleToken;
8942
8943            $progressContainer.css('display', 'block');
8944            $progressBar.css({
8945                transform: 'scaleX(1)',
8946                transition: 'none'
8947            });
8948
8949            runOnNextPaint(function() {
8950                if (cycleToken !== galleryProgressCycleToken || !$progressBar.length) {
8951                    return;
8952                }
8953                $progressBar.css({
8954                    transform: 'scaleX(0)',
8955                    transition: 'transform ' + progressDuration + 'ms linear'
8956                });
8957            });
8958        }
8959
8960        function scheduleGalleryAutoplay() {
8961            clearGalleryAutoplayTimer();
8962
8963            if (!shouldShowGallerySlideshowProgress()) {
8964                setGalleryAutoplayProgressVisible(false);
8965                return;
8966            }
8967
8968            if (!canRunGalleryAutoplay()) {
8969                setGalleryAutoplayProgressVisible(true);
8970                return;
8971            }
8972
8973            if ($container.data('jzsaGalleryAnimating')) {
8974                setGalleryAutoplayProgressVisible(true);
8975                gallerySlideshowTimer = window.setTimeout(function() {
8976                    scheduleGalleryAutoplay();
8977                }, GALLERY_AUTOPLAY_RETRY_MS);
8978                return;
8979            }
8980
8981            startGalleryAutoplayProgressCycle();
8982            gallerySlideshowTimer = window.setTimeout(function() {
8983                runOnNextPaint(function() {
8984                    if (!canRunGalleryAutoplay() || $container.data('jzsaGalleryAnimating')) {
8985                        scheduleGalleryAutoplay();
8986                        return;
8987                    }
8988
8989                    var nextPage = paginationState.currentPage + 1;
8990                    if (nextPage >= paginationState.totalPages) {
8991                        nextPage = 0;
8992                    }
8993
8994                    paginationState.currentPage = nextPage;
8995                    renderCurrentGalleryPage({
8996                        animate: true,
8997                        direction: 1
8998                    });
8999                });
9000            }, gallerySlideshowDelay * MILLISECONDS_PER_SECOND);
9001        }
9002
9003        function syncGalleryAutoplayState() {
9004            clearGalleryAutoplayTimer();
9005            scheduleGalleryAutoplay();
9006        }
9007
9008        var gallerySlideshowNamespace = '.jzsaGallerySlideshow-' + ($container.attr('id') || 'gallery');
9009        $shell.off('mouseenter' + gallerySlideshowNamespace + ' mouseleave' + gallerySlideshowNamespace);
9010        if (!interactionLock) {
9011            $shell.on('mouseenter' + gallerySlideshowNamespace, function() {
9012                gallerySlideshowPausedByHover = true;
9013                clearGalleryAutoplayTimer();
9014                scheduleGalleryAutoplay();
9015            });
9016            $shell.on('mouseleave' + gallerySlideshowNamespace, function() {
9017                gallerySlideshowPausedByHover = false;
9018                scheduleGalleryAutoplay();
9019            });
9020        }
9021
9022        function renderCurrentGalleryPage(options) {
9023            // Pause any playing videos before re-rendering the page
9024            $container.find('.jzsa-video-player').each(function() {
9025                if (!this.paused) { this.pause(); }
9026            });
9027
9028            var renderOptions = options || {};
9029            var useScroller = galleryScrollable && galleryRows > 0;
9030            var gap = parseInt(readGalleryAttr($container, 'gap'), 10) || 4;
9031
9032            if (layout === 'justified') {
9033                var justified = getJustifiedLayoutData($container, allPhotos);
9034                $container[0].style.setProperty('--jzsa-gallery-gap', justified.gap + 'px');
9035
9036                if (useScroller) {
9037                    renderJustifiedRows(
9038                        $container,
9039                        justified.rows,
9040                        justified.containerWidth,
9041                        justified.targetHeight,
9042                        justified.gap
9043                    );
9044
9045                    var totalJustifiedRows = justified.rows.length;
9046                    var isJustifiedScrollable = totalJustifiedRows > galleryRows;
9047                    if (isJustifiedScrollable) {
9048                        var visibleHeight = (galleryRows * justified.targetHeight) + ((galleryRows - 1) * gap);
9049                        setGalleryScrollableState($container, true, visibleHeight);
9050                    } else {
9051                        setGalleryScrollableState($container, false);
9052                    }
9053
9054                    paginationState.currentPage = 0;
9055                    paginationState.totalPages = 1;
9056					setupGalleryPaginationControls($container, paginationState, function() {}, {
9057						showCounter: true,
9058						showSinglePageInfo: true
9059					});
9060                    setGalleryPaginationHeightState($container, false);
9061                    setupGalleryMouseInteractions($container, {
9062                        enabled: !interactionLock && isJustifiedScrollable,
9063                        mode: 'scroll'
9064                    });
9065                } else {
9066                    setGalleryScrollableState($container, false);
9067
9068                    var rowsPerPage = galleryRows > 0 ? galleryRows : (justified.rows.length || 1);
9069                    paginationState.totalPages = justified.rows.length > 0 ? Math.ceil(justified.rows.length / rowsPerPage) : 1;
9070                    clampGalleryPage(paginationState);
9071
9072                    var rowStart = paginationState.currentPage * rowsPerPage;
9073                    var rowsForPage = (galleryRows > 0)
9074                        ? justified.rows.slice(rowStart, rowStart + rowsPerPage)
9075                        : justified.rows;
9076
9077                    var renderJustifiedPage = function() {
9078                        renderJustifiedRows(
9079                            $container,
9080                            rowsForPage,
9081                            justified.containerWidth,
9082                            justified.targetHeight,
9083                            justified.gap
9084                        );
9085                    };
9086
9087                    if (renderOptions.animate) {
9088                        animateGalleryPageTransition($container, renderOptions.direction || 1, renderJustifiedPage);
9089                    } else {
9090                        renderJustifiedPage();
9091                    }
9092
9093                    var fixedJustifiedRows = galleryRows > 0 ? galleryRows : rowsPerPage;
9094                    var fixedJustifiedHeight =
9095                        (fixedJustifiedRows * justified.targetHeight) +
9096                        ((fixedJustifiedRows - 1) * gap);
9097                    setGalleryPaginationHeightState(
9098                        $container,
9099                        paginationState.totalPages > 1 && fixedJustifiedRows > 0,
9100                        fixedJustifiedHeight
9101                    );
9102
9103                    var onJustifiedPageChange = function(nextPage, direction) {
9104                        paginationState.currentPage = nextPage;
9105                        renderCurrentGalleryPage({
9106                            animate: true,
9107                            direction: direction
9108                        });
9109                    };
9110
9111                    setupGalleryPaginationControls($container, paginationState, onJustifiedPageChange, {
9112                        showCounter: true,
9113                        showAutoplayProgress: shouldShowGallerySlideshowProgress(),
9114                        showSlideshowControls: gallerySlideshowEnabled,
9115                        isAutoplayRunning: function() {
9116                            return gallerySlideshowEnabled && !gallerySlideshowPau
9116sedByUser;
9117                        },
9118                        onToggleAutoplay: function() {
9119                            gallerySlideshowPausedByUser = !gallerySlideshowPausedByUser;
9120                            syncGalleryAutoplayState();
9121                            renderCurrentGalleryPage();
9122                        }
9123                    });
9124                    setupGalleryMouseInteractions($container, {
9125                        enabled: !interactionLock && paginationState.totalPages > 1,
9126                        mode: 'pagination',
9127                        onPageSwipe: function(direction) {
9128                            if ($container.data('jzsaGalleryAnimating')) {
9129                                return;
9130                            }
9131
9132                            var nextPage = paginationState.currentPage + direction;
9133                            if (nextPage < 0) {
9134                                nextPage = paginationState.totalPages - 1;
9135                            } else if (nextPage > paginationState.totalPages - 1) {
9136                                nextPage = 0;
9137                            }
9138
9139                            onJustifiedPageChange(nextPage, direction);
9140                        },
9141                        onPageDragStart: function(direction) {
9142                            if ($container.data('jzsaGalleryAnimating')) {
9143                                return null;
9144                            }
9145
9146                            var currentPage = paginationState.currentPage;
9147                            var nextPage = currentPage + direction;
9148                            if (nextPage < 0) {
9149                                nextPage = paginationState.totalPages - 1;
9150                            } else if (nextPage > paginationState.totalPages - 1) {
9151                                nextPage = 0;
9152                            }
9153
9154                            var nextRowStart = nextPage * rowsPerPage;
9155                            var nextRowsForPage = (galleryRows > 0)
9156                                ? justified.rows.slice(nextRowStart, nextRowStart + rowsPerPage)
9157                                : justified.rows;
9158
9159                            return createGalleryPageDragTransition(
9160                                $container,
9161                                direction,
9162                                function() {
9163                                    renderJustifiedRows(
9164                                        $container,
9165                                        nextRowsForPage,
9166                                        justified.containerWidth,
9167                                        justified.targetHeight,
9168                                        justified.gap
9169                                    );
9170                                },
9171                                function() {
9172                                    renderJustifiedRows(
9173                                        $container,
9174                                        rowsForPage,
9175                                        justified.containerWidth,
9176                                        justified.targetHeight,
9177                                        justified.gap
9178                                    );
9179                                },
9180                                function(committed) {
9181                                    paginationState.currentPage = committed ? nextPage : currentPage;
9182                                    renderCurrentGalleryPage();
9183                                }
9184                            );
9185                        }
9186                    });
9187                }
9188            } else {
9189                var activeColumns = getUniformColumnsForViewport($container);
9190                var allItems = getGalleryPageItems(allPhotos, 0, allPhotos.length || 1);
9191                var explicitUniformHeight = parseInt(readGalleryAttr($container, 'explicit-height'), 10);
9192                var fillUniformRowHeight = 0;
9193
9194                if (gallerySizing === 'fill' && galleryRows > 0 && !isNaN(explicitUniformHeight) && explicitUniformHeight > 0) {
9195                    fillUniformRowHeight = (explicitUniformHeight - ((galleryRows - 1) * gap)) / galleryRows;
9196                    if (!(fillUniformRowHeight > 0)) {
9197                        fillUniformRowHeight = 0;
9198                    }
9199                }
9200
9201                if (useScroller) {
9202                    buildUniformGallery($container, allItems, {
9203                        tileHeight: fillUniformRowHeight
9204                    });
9205
9206                    var totalUniformRows = activeColumns > 0 ? Math.ceil(allPhotos.length / activeColumns) : 0;
9207                    var isUniformScrollable = totalUniformRows > galleryRows;
9208                    if (isUniformScrollable) {
9209                        var rowHeight = fillUniformRowHeight;
9210
9211                        if (!(rowHeight > 0)) {
9212                            var $firstThumb = $container.find('.jzsa-gallery-thumb').first();
9213                            rowHeight = $firstThumb.length ? $firstThumb.outerHeight() : 0;
9214                        }
9215
9216                        if (!rowHeight || rowHeight <= 0) {
9217                            var containerWidth = getGalleryContainerWidth($container);
9218                            var cellWidth = (containerWidth - (gap * (activeColumns - 1))) / activeColumns;
9219                            rowHeight = cellWidth * 0.75; // 4:3 aspect ratio in grid layout
9220                        }
9221
9222                        var visibleUniformHeight = (galleryRows * rowHeight) + ((galleryRows - 1) * gap);
9223                        setGalleryScrollableState($container, true, visibleUniformHeight);
9224
9225                        // Second pass: once scrollbar appears, available width can
9226                        // shrink slightly, which changes thumbnail height. Re-measure
9227                        // and apply a corrected max-height to avoid showing a strip
9228                        // of the next row.
9229                        var adjustedRowHeight = fillUniformRowHeight;
9230                        if (!(adjustedRowHeight > 0)) {
9231                            var $adjustedThumb = $container.find('.jzsa-gallery-thumb').first();
9232                            adjustedRowHeight = $adjustedThumb.length ? $adjustedThumb.outerHeight() : 0;
9233                        }
9234                        if (adjustedRowHeight && adjustedRowHeight > 0) {
9235                            var adjustedVisibleHeight = (galleryRows * adjustedRowHeight) + ((galleryRows - 1) * gap) + 1;
9236                            setGalleryScrollableState($container, true, adjustedVisibleHeight);
9237                        }
9238                    } else {
9239                        setGalleryScrollableState($container, false);
9240                    }
9241
9242                    paginationState.currentPage = 0;
9243                    paginationState.totalPages = 1;
9244					setupGalleryPaginationControls($container, paginationState, function() {}, {
9245						showCounter: true,
9246						showSinglePageInfo: true
9247					});
9248                    setGalleryPaginationHeightState($container, false);
9249                    setupGalleryMouseInteractions($container, {
9250                        enabled: !interactionLock && isUniformScrollable,
9251                        mode: 'scroll'
9252                    });
9253                } else {
9254                    setGalleryScrollableState($container, false);
9255
9256                    var photosPerPage = galleryRows > 0 ? (galleryRows * activeColumns) : allPhotos.length;
9257                    if (photosPerPage <= 0) {
9258                        photosPerPage = allPhotos.length > 0 ? allPhotos.length : 1;
9259                    }
9260
9261                    paginationState.totalPages = allPhotos.length > 0 ? Math.ceil(allPhotos.length / photosPerPage) : 1;
9262                    clampGalleryPage(paginationState);
9263
9264                    var pageItems = getGalleryPageItems(allPhotos, paginationState.currentPage, photosPerPage);
9265                    var shouldRenderPlaceholders = paginationState.totalPages > 1 && galleryRows > 0;
9266                    var placeholderCount = shouldRenderPlaceholders
9267                        ? Math.max(0, photosPerPage - pageItems.length)
9268                        : 0;
9269                    var pageTileHeight = fillUniformRowHeight;
9270                    var renderUniformPage = function() {
9271                        buildUniformGallery($container, pageItems, {
9272                            placeholderCount: placeholderCount,
9273                            tileHeight: pageTileHeight
9274                        });
9275                    };
9276
9277                    if (renderOptions.animate) {
9278                        animateGalleryPageTransition($container, renderOptions.direction || 1, renderUniformPage);
9279                    } else {
9280                        renderUniformPage();
9281                    }
9282
9283                    var fixedUniformRows = galleryRows > 0 ? galleryRows : Math.max(1, Math.ceil(allPhotos.length / activeColumns));
9284                    var fixedUniformRowHeight = pageTileHeight;
9285                    if (!(fixedUniformRowHeight > 0)) {
9286                        var $fixedThumb = $container.find('.jzsa-gallery-thumb').first();
9287                        fixedUniformRowHeight = $fixedThumb.length ? $fixedThumb.outerHeight() : 0;
9288                    }
9289                    if (!fixedUniformRowHeight || fixedUniformRowHeight <= 0) {
9290                        var fixedContainerWidth = getGalleryContainerWidth($container);
9291                        var fixedCellWidth = (fixedContainerWidth - (gap * (activeColumns - 1))) / activeColumns;
9292                        fixedUniformRowHeight = fixedCellWidth * 0.75; // 4:3 aspect ratio in grid layout
9293                    }
9294
9295                    var fixedUniformHeight =
9296                        (fixedUniformRows * fixedUniformRowHeight) +
9297                        ((fixedUniformRows - 1) * gap);
9298                    setGalleryPaginationHeightState(
9299                        $container,
9300                        paginationState.totalPages > 1 && fixedUniformRows > 0,
9301                        fixedUniformHeight
9302                    );
9303
9304                    var onUniformPageChange = function(nextPage, direction) {
9305                        paginationState.currentPage = nextPage;
9306                        renderCurrentGalleryPage({
9307                            animate: true,
9308                            direction: direction
9309                        });
9310                    };
9311
9312                    setupGalleryPaginationControls($container, paginationState, onUniformPageChange, {
9313                        showCounter: true,
9314                        showAutoplayProgress: shouldShowGallerySlideshowProgress(),
9315                        showSlideshowControls: gallerySlideshowEnabled,
9316                        isAutoplayRunning: function() {
9317                            return gallerySlideshowEnabled && !gallerySlideshowPausedByUser;
9318                        },
9319                        onToggleAutoplay: function() {
9320                            gallerySlideshowPausedByUser = !gallerySlideshowPausedByUser;
9321                            syncGalleryAutoplayState();
9322                            renderCurrentGalleryPage();
9323                        }
9324                    });
9325                    setupGalleryMouseInteractions($container, {
9326                        enabled: !interactionLock && paginationState.totalPages > 1,
9327                        mode: 'pagination',
9328                        onPageSwipe: function(direction) {
9329                            if ($container.data('jzsaGalleryAnimating')) {
9330                                return;
9331                            }
9332
9333                            var nextPage = paginationState.currentPage + direction;
9334                            if (nextPage < 0) {
9335                                nextPage = paginationState.totalPages - 1;
9336                            } else if (nextPage > paginationState.totalPages - 1) {
9337                                nextPage = 0;
9338                            }
9339
9340                            onUniformPageChange(nextPage, direction);
9341                        },
9342                        onPageDragStart: function(direction) {
9343                            if ($container.data('jzsaGalleryAnimating')) {
9344                                return null;
9345                            }
9346
9347                            var currentPage = paginationState.currentPage;
9348                            var nextPage = currentPage + direction;
9349                            if (nextPage < 0) {
9350                                nextPage = paginationState.totalPages - 1;
9351                            } else if (nextPage > paginationState.totalPages - 1) {
9352                                nextPage = 0;
9353                            }
9354
9355                            var nextPageItems = getGalleryPageItems(allPhotos, nextPage, photosPerPage);
9356                            var nextPlaceholderCount = shouldRenderPlaceholders
9357                                ? Math.max(0, photosPerPage - nextPageItems.length)
9358                                : 0;
9359
9360                            return createGalleryPageDragTransition(
9361                                $container,
9362                                direction,
9363                                function() {
9364                                    buildUniformGallery($container, nextPageItems, {
9365                                        placeholderCount: nextPlaceholderCount,
9366                                        tileHeight: pageTileHeight
9367                                    });
9368                                },
9369                                function() {
9370                                    buildUniformGallery($container, pageItems, {
9371                                        placeholderCount: placeholderCount,
9372                                        tileHeight: pageTileHeight
9373                                    });
9374                                },
9375                                function(committed) {
9376                                    paginationState.currentPage = committed ? nextPage : currentPage;
9377                                    renderCurrentGalleryPage();
9378                                }
9379                            );
9380                        }
9381                    });
9382                }
9383            }
9384
9385            watchGalleryInitialThumbLoad();
9386            scheduleGalleryAutoplay();
9387        }
9388
9389        renderCurrentGalleryPage();
9390
9391        // Re-render gallery page on resize:
9392        // - justified layout depends on container width (re-render only when width changes)
9393        // - grid layout only needs re-render when the column breakpoint changes
9394        var resizeNamespace = 'resize.jzsa-gallery-' + $container.attr('id');
9395        $(window).off(resizeNamespace);
9396        var resizeTimer;
9397        var resizePending = false;
9398        var lastResizeColumns = layout !== 'justified' ? getUniformColumnsForViewport($container) : 0;
9399        var lastResizeWidth = layout === 'justified' ? getGalleryContainerWidth($container) : 0;
9400        $(window).on(resizeNamespace, function() {
9401            clearTimeout(resizeTimer);
9402            resizeTimer = setTimeout(function() {
9403                // For grid layout, skip re-render if column count hasn't changed.
9404                if (layout !== 'justified') {
9405                    var currentColumns = getUniformColumnsForViewport($container);
9406                    if (currentColumns === lastResizeColumns) {
9407                        return;
9408                    }
9409                    lastResizeColumns = currentColumns;
9410                } else {
9411                    // For justified layout, skip re-render if container width hasn't changed.
9412                    // On iOS, browser chrome collapse/expand fires resize events but only changes
9413                    // the viewport height, not the content width. Without this guard, justified
9414                    // layout would destructively re-render on every scroll-triggered chrome toggle.
9415                    var currentWidth = getGalleryContainerWidth($container);
9416                    if (currentWidth === lastResizeWidth) {
9417                        return;
9418                    }
9419                    lastResizeWidth = currentWidth;
9420                }
9421                runOnNextPaint(function() {
9422                    if ($container.hasClass('jzsa-video-playing')) {
9423                        resizePending = true;
9424                        return;
9425                    }
9426                    renderCurrentGalleryPage();
9427                });
9428            }, 150);
9429        });
9430
9431        // When video playback ends, flush any deferred resize re-render
9432        $container.on('jzsa:video-stopped', function() {
9433            if (resizePending) {
9434                resizePending = false;
9435                renderCurrentGalleryPage();
9436            }
9437        });
9438
9439        // When the fullscreen slideshow exits, it fires this event so the
9440        // gallery can navigate to the page containing the photo the user
9441        // was last viewing.  All layout/pagination state lives in this
9442        // closure, so the computation happens here rather than in the
9443        // fullscreen exit handler.
9444        $container.on('jzsa:focus-index', function(_e, targetIndex) {
9445            if (typeof targetIndex !== 'number' || targetIndex < 0 || targetIndex >= allPhotos.length) {
9446                return;
9447            }
9448
9449            // Scrollable gallery: scroll the item into view inside the container.
9450            // Use getBoundingClientRect() rather than offsetTop so the calculation
9451            // is correct for both grid (direct children) and justified layout
9452            // (items nested inside .jzsa-justified-row elements).
9453            var $item = $container.find('[data-index="' + targetIndex + '"]').first();
9454            if (galleryScrollable && galleryRows > 0 &&
9455                $container[0].scrollHeight > $container[0].clientHeight) {
9456                if ($item.length) {
9457                    var containerRect = $container[0].getBoundingClientRect();
9458                    var itemRect = $item[0].getBoundingClientRect();
9459                    $container[0].scrollTop = $container[0].scrollTop
9460                        + itemRect.top - containerRect.top
9461                        - ($container[0].clientHeight - itemRect.height) / 2;
9462                }
9463                return;
9464            }
9465
9466            // Paginated gallery: compute the target page and re-render
9467            if (paginationState.totalPages <= 1) {
9468                // No pagination and no internal scroller - all items are in the
9469                // normal page flow.  Scroll the page itself to the target item.
9470                if ($item.length) {
9471                    $item[0].scrollIntoView({ block: 'center', behavior: 'instant' });
9472                }
9473                return;
9474            }
9475
9476            var targetPage;
9477            if (layout === 'justified') {
9478                var justified = getJustifiedLayoutData($container, allPhotos);
9479                var rowsPerPage = galleryRows > 0 ? galleryRows : (justified.rows.length || 1);
9480                var photosSeen = 0;
9481                var targetRow = 0;
9482                for (var r = 0; r < justified.rows.length; r++) {
9483                    photosSeen += justified.rows[r].length;
9484                    if (targetIndex < photosSeen) {
9485                        targetRow = r;
9486                        break;
9487                    }
9488                }
9489                targetPage = Math.floor(targetRow / rowsPerPage);
9490            } else {
9491                var activeColumns = getUniformColumnsForViewport($container);
9492                var photosPerPage = galleryRows > 0 ? (galleryRows * activeColumns) : allPhotos.length;
9493                if (photosPerPage <= 0) {
9494                    photosPerPage = allPhotos.length > 0 ? allPhotos.length : 1;
9495                }
9496                targetPage = Math.floor(targetIndex / photosPerPage);
9497            }
9498
9499            if (targetPage !== paginationState.currentPage) {
9500                paginationState.currentPage = targetPage;
9501                renderCurrentGalleryPage();
9502            }
9503        });
9504
9505        // Build the fullscreen slideshow and initialize it eagerly (same as
9506        // player/carousel modes - Swiper is always ready, not lazily created).
9507        slideshowId = buildGallerySlideshow($container);
9508        $slideshow = $('#' + slideshowId);
9509        initializeSwiper($slideshow[0], 'slideshow');
9510        var showHintsOnGalleryFullscreen = null;
9511        if (fullscreenToggle !== 'button-only' && fullscreenToggle !== 'disabled') {
9512            showHintsOnGalleryFullscreen = createHintSystem(slideshowId);
9513        }
9514        var showHintsOnGalleryLightbox = null;
9515        if (lightboxMode !== 'button-only' && lightboxMode !== 'disabled') {
9516            showHintsOnGalleryLightbox = createHintSystem(slideshowId, 'jzsa-lightbox-hints-counter', [
9517                'Click / tap / swipe left or right to browse photos',
9518                'Press Esc or tap \u29C9 to close Lightbox'
9519            ]);
9520        }
9521
9522        var fullscreenSyncNamespace = '.jzsaGalleryFullscreen-' + ($container.attr('id') || 'gallery');
9523        $(document).off(
9524            'fullscreenchange' + fullscreenSyncNamespace +
9525            ' webkitfullscreenchange' + fullscreenSyncNamespace +
9526            ' mozfullscreenchange' + fullscreenSyncNamespace +
9527            ' MSFullscreenChange' + fullscreenSyncNamespace
9528        );
9529        $(document).on(
9530            'fullscreenchange' + fullscreenSyncNamespace +
9531            ' webkitfullscreenchange' + fullscreenSyncNamespace +
9532            ' mozfullscreenchange' + fullscreenSyncNamespace +
9533            ' MSFullscreenChange' + fullscreenSyncNamespace,
9534            function() {
9535                window.setTimeout(syncGalleryAutoplayState, 80);
9536            }
9537        );
9538
9539        $slideshow.off(
9540            'click' + fullscreenSyncNamespace +
9541            ' dblclick' + fullscreenSyncNamespace +
9542            ' touchend' + fullscreenSyncNamespace
9543        );
9544        $slideshow.on(
9545            'click' + fullscreenSyncNamespace +
9546            ' dblclick' + fullscreenSyncNamespace +
9547            ' touchend' + fullscreenSyncNamespace,
9548            function() {
9549                window.setTimeout(syncGalleryAutoplayState, 80);
9550            }
9551        );
9552
9553        var fullscreenToggle =
9554            $container.attr('data-fullscreen-toggle') || 'button-only';
9555        if (interactionLock) {
9556            fullscreenToggle = 'disabled';
9557        }
9558        var lightboxMode = interactionLock ? 'disabled' : ($container.attr('data-lightbox-toggle') || 'disabled');
9559        // In both-viewer mode, a button-only Lightbox must not mask an explicit
9560        // Fullscreen click/double-click gesture. The legacy migration maps a
9561        // single gesture owner to the corresponding mode-specific trigger.
9562        var expandToggle = (lightboxMode !== 'disabled') ? lightboxMode : fullscreenToggle;
9563        var expandMode = 'lightbox';
9564        if (lightboxMode !== 'click' && lightboxMode !== 'double-click' &&
9565            (fullscreenToggle === 'click' || fullscreenToggle === 'double-click')) {
9566            expandToggle = fullscreenToggle;
9567            expandMode = 'fullscreen';
9568        }
9569
9570        function openGalleryPlayerAtIndex(index, forcedMode) {
9571            if (interactionLock) {
9572                return;
9573            }
9574
9575            var safeIndex = typeof index === 'number' && index >= 0 ? index : 0;
9576            var swiper = swipers[slideshowId];
9577            if (swiper) {
9578                if (swiper.params.loop && typeof swiper.slideToLoop === 'function') {
9579                    swiper.slideToLoop(safeIndex, 0, false);
9580                } else {
9581                    swiper.slideTo(safeIndex, 0, false);
9582                }
9583            }
9584            clearGalleryAutoplayTimer();
9585            if (forcedMode === 'lightbox') {
9586                toggleLightbox($slideshow[0], showHintsOnGalleryLightbox);
9587            } else if (forcedMode === 'fullscreen') {
9588                toggleFullscreen($slideshow[0], showHintsOnGalleryFullscreen);
9589            } else if (elementUsesLightbox($slideshow[0])) {
9590                toggleLightbox($slideshow[0], showHintsOnGalleryLightbox);
9591            } else {
9592                toggleFullscreen($slideshow[0], showHintsOnGalleryFullscreen);
9593            }
9594        }
9595
9596        function getGalleryPhotoIndexFromElement(targetEl) {
9597            var $target = $(targetEl);
9598            var direct = parseInt($target.attr('data-index'), 10);
9599            if (!isNaN(direct) && direct >= 0) {
9600                return direct;
9601            }
9602
9603            var closest = parseInt($target.closest('[data-index]').attr('data-index'), 10);
9604            if (!isNaN(closest) && closest >= 0) {
9605                return closest;
9606            }
9607
9608            return 0;
9609        }
9610
9611        function openGalleryPlayerFromThumb(targetEl, gestureMode) {
9612            var index = getGalleryPhotoIndexFromElement(targetEl);
9613            var $btn = $(targetEl).closest('.jzsa-gallery-thumb-fs-btn');
9614            var forcedMode;
9615            if ($btn.hasClass('swiper-button-lightbox')) {
9616                forcedMode = 'lightbox';
9617            } else if ($btn.hasClass('swiper-button-fullscreen')) {
9618                forcedMode = 'fullscreen';
9619            }
9620            openGalleryPlayerAtIndex(index, gestureMode || forcedMode);
9621        }
9622
9623        function isGalleryVideoInteractionTarget(targetEl) {
9624            return $(targetEl).closest('.jzsa-gallery-item-video .jzsa-video-wrapper').length > 0;
9625        }
9626
9627        if (!interactionLock && expandToggle === 'click') {
9628            $container.on('click', '.jzsa-gallery-thumb', function(e) {
9629                if ($container.data('jzsaGallerySuppressClick')) {
9630                    return;
9631                }
9632                if (isGalleryVideoInteractionTarget(e.target)) {
9633                    return;
9634                }
9635                e.preventDefault();
9636                openGalleryPlayerFromThumb(this, expandMode);
9637            });
9638
9639            // Video items: click anywhere except plyr controls opens fullscreen.
9640            $container.on('click', '.jzsa-gallery-item-video', function(e) {
9641                if ($container.data('jzsaGallerySuppressClick')) {
9642                    return;
9643                }
9644                if (shouldIgnoreClick(e.target)) {
9645                    return;
9646                }
9647                e.preventDefault();
9648                openGalleryPlayerFromThumb(this, expandMode);
9649            });
9650        } else if (!interactionLock && expandToggle === 'double-click') {
9651            $container.on('dblclick', '.jzsa-gallery-thumb', function(e) {
9652                if (isGalleryVideoInteractionTarget(e.target)) {
9653                    return;
9654                }
9655                e.preventDefault();
9656                openGalleryPlayerFromThumb(this, expandMode);
9657            });
9658
9659            // Mobile/touch fallback for double-click mode.
9660            $container.on('touchend', '.jzsa-gallery-thumb', function(e) {
9661                if ($container.data('jzsaGallerySuppressClick')) {
9662                    return;
9663                }
9664                if (isGalleryVideoInteractionTarget(e.target)) {
9665                    return;
9666                }
9667                handleDoubleTap(e, function() {
9668                    openGalleryPlayerFromThumb(e.currentTarget || e.target, expandMode);
9669                });
9670            });
9671
9672            // Video items: double-click anywhere except plyr controls opens fullscreen.
9673            $container.on('dblclick', '.jzsa-gallery-item-video', function(e) {
9674                if (shouldIgnoreClick(e.target)) {
9675                    return;
9676                }
9677                e.preventDefault();
9678                openGalleryPlayerFromThumb(this, expandMode);
9679            });
9680
9681            // Video items: mobile/touch double-tap fallback.
9682            $container.on('touchend', '.jzsa-gallery-item-video', function(e) {
9683                if ($container.data('jzsaGallerySuppressClick')) {
9684                    return;
9685                }
9686                if (shouldIgnoreClick(e.target)) {
9687                    return;
9688                }
9689                handleDoubleTap(e, function() {
9690                    openGalleryPlayerFromThumb(e.currentTarget || e.target, expandMode);
9691                });
9692            });
9693        }
9694
9695        // Attach corner-button click/keyboard handlers (unless the viewer is disabled).
9696        if (!interactionLock && expandToggle !== 'disabled') {
9697            $container.on('click', '.jzsa-gallery-thumb-fs-btn', function(e) {
9698                e.preventDefault();
9699                e.stopPropagation();
9700                openGalleryPlayerFromThumb(this);
9701            });
9702            $container.on('keydown', '.jzsa-gallery-thumb-fs-btn', function(e) {
9703                if (e.key !== 'Enter' && e.key !== ' ' && e.keyCode !== 13 && e.keyCode !== 32) {
9704                    return;
9705                }
9706                e.preventDefault();
9707                e.stopPropagation();
9708                openGalleryPlayerFromThumb(this);
9709            });
9710        }
9711
9712        // Attach download button click handler on gallery thumbnails.
9713        if (!interactionLock) {
9714            $container.on('click', '.jzsa-gallery-thumb-download-btn', function(e) {
9715                e.preventDefault();
9716                e.stopPropagation();
9717
9718                var index = getGalleryPhotoIndexFromElement(this);
9719                var photo = allPhotos[index];
9720                if (!photo) {
9721                    return;
9722                }
9723
9724                var mediaType = photo.type === 'video' ? 'video' : 'photo';
9725                var mediaUrl = mediaType === 'video'
9726                    ? (photo.video || photo.full || photo.preview)
9727                    : (photo.full || photo.preview);
9728                if (!mediaUrl) {
9729                    return;
9730                }
9731
9732                var filename = (mediaType === 'video' ? 'video-' : 'photo-') + (index + 1) + (mediaType === 'video' ? '.mp4' : '.jpg');
9733                var $btn = $(this);
9734
9735                function restoreThumbButtonState() {
9736                    $btn.css('opacity', '');
9737                }
9738
9739                function requestGalleryProxyDownload(allowLargeDownload) {
9740                    $btn.css('opacity', '0.5');
9741                    showDownloadStatus(allowLargeDownload ? 'Preparing large download...' : 'Preparing download...', false);
9742
9743                    var requestData = {
9744                        action: 'jzsa_download_image',
9745                        nonce: jzsaAjax.downloadNonce,
9746                        media_url: mediaUrl,
9747                        image_url: mediaUrl,
9748                        filename: filename
9749                    };
9750                    if (galleryDownloadWarningSizeBytes !== null) {
9751                        requestData.warning_size_bytes = galleryDownloadWarningSizeBytes;
9752                    }
9753                    if (allowLargeDownload) {
9754                        requestData.allow_large_download = 'true';
9755                    }
9756
9757                    $.ajax({
9758                        url: jzsaAjax.ajaxUrl,
9759                        type: 'POST',
9760                        data: requestData,
9761                        xhrFields: { responseType: 'blob' },
9762                        success: function(blob) {
9763                            detectDownloadErrorFromBlob(blob, function(blobErrorData) {
9764                                if (blobErrorData) {
9765                                    if (blobErrorData.requiresLargeDownloadConfirmation && !allowLargeDownload) {
9766                                        restoreThumbButtonState();
9767                                        if (confirmLargeDownload(blobErrorData)) {
9768                                            requestGalleryProxyDownload(true);
9769                                        } else {
9770                                            showDownloadStatus('Download canceled.', false);
9771                                        }
9772                                        return;
9773                                    }
9774
9775                                    showDownloadErrorMessage(blobErrorData.message);
9776                                    restoreThumbButtonState();
9777                                    return;
9778                                }
9779                                downloadBlobToFile(blob, filename);
9780                                showDownloadStatus('Download started.', false);
9781                                restoreThumbButtonState();
9782                            });
9783                        },
9784                        error: function(xhr) {
9785                            function handleGalleryDownloadErrorData(errorData) {
9786                                if (errorData.requiresLargeDownloadConfirmation && !allowLargeDownload) {
9787                                    restoreThumbButtonState();
9788                                    if (confirmLargeDownload(errorData)) {
9789                                        requestGalleryProxyDownload(true);
9790                                    } else {
9791                                        showDownloadStatus('Download canceled.', false);
9792                                    }
9793                                    return;
9794                                }
9795
9796                                showDownloadErrorMessage(errorData.message);
9797                                restoreThumbButtonState();
9798                            }
9799
9800                            function openGalleryDirectDownloadFallback() {
9801                                var link = document.createElement('a');
9802                                link.href = mediaUrl;
9803                                link.download = filename;
9804                                link.target = '_blank';
9805                                link.rel = 'noopener noreferrer';
9806                                document.body.appendChild(link);
9807                                link.click();
9808                                document.body.removeChild(link);
9809                                showDownloadStatus('Opening direct download...', false);
9810                                restoreThumbButtonState();
9811                            }
9812
9813                            var ajaxErrorData = getAjaxErrorData(xhr);
9814                            if (ajaxErrorData) {
9815                                handleGalleryDownloadErrorData(ajaxErrorData);
9816                                return;
9817                            }
9818
9819                            if (xhr && xhr.response && typeof xhr.response.text === 'function') {
9820                                detectDownloadErrorFromBlob(xhr.response, function(blobErrorData) {
9821                                    if (blobErrorData) {
9822                                        handleGalleryDownloadErrorData(blobErrorData);
9823                                        return;
9824                                    }
9825
9826                                    openGalleryDirectDownloadFallback();
9827                                });
9828                                return;
9829                            }
9830
9831                            openGalleryDirectDownloadFallback();
9832                        }
9833                    });
9834                }
9835
9836                requestGalleryProxyDownload(false);
9837            });
9838        }
9839
9840        // GALLERY TOUCH REVEAL: show per-item buttons on touch devices.
9841        // "on-interaction": interacting with one item activates it persistently
9842        // until another item becomes active or it leaves the viewport;
9843        // fullscreen return restores it.
9844        // Passive listeners so iOS/Android never have to wait for JS before
9845        // committing a scroll frame.
9846        (function() {
9847            var galleryEl = $container[0];
9848            var buttonsMode = $container.attr('data-gallery-buttons-on-mobile') || 'on-interaction';
9849            var isTouchDevice = hasTouchInput();
9850
9851            // gallery-buttons-on-mobile="always": mark container so CSS keeps buttons permanently visible.
9852            if (buttonsMode === 'always') {
9853                if (isTouchDevice) {
9854                    $container.addClass('jzsa-touch-device');
9855                }
9856                return; // No tap/flash handling needed.
9857            }
9858
9859            if (!isTouchDevice) {
9860                return;
9861            }
9862
9863            function closestItem(target) {
9864                return target && target.closest ? target.closest('.jzsa-gallery-item') : null;
9865            }
9866
9867            // Hide buttons when item scrolls out of view (IntersectionObserver) or when another item is revealed.
9868            var touchObserver = typeof IntersectionObserver !== 'undefined'
9869                ? new IntersectionObserver(function(entries) {
9870                    entries.forEach(function(entry) {
9871                        if (!entry.isIntersecting) {
9872                            touchObserver.unobserve(entry.target);
9873                            $(entry.target).removeClass('jzsa-item-touched');
9874                        }
9875                    });
9876                }, { threshold: 0.1 })
9877                : null;
9878
9879            function revealItem($item) {
9880                $container.find('.jzsa-item-touched').not($item).each(function() {
9881                    if (touchObserver) { touchObserver.unobserve(this); }
9882                    $(this).removeClass('jzsa-item-touched');
9883                });
9884                $item.addClass('jzsa-item-touched');
9885                if (touchObserver) { touchObserver.observe($item[0]); }
9886            }
9887
9888            var touchStartItem = null;
9889            var touchStartX = null;
9890            var touchStartY = null;
9891            var TOUCH_REVEAL_MOVE_TOLERANCE_PX = 8;
9892
9893            function getItemAtClientPoint(clientX, clientY) {
9894                if (clientX == null || clientY == null || !document.elementFromPoint) {
9895                    return null;
9896                }
9897                var el = document.elementFromPoint(clientX, clientY);
9898                return closestItem(el);
9899            }
9900
9901            function resetTouchTracking() {
9902                touchStartItem = null;
9903                touchStartX = null;
9904                touchStartY = null;
9905            }
9906
9907            $container.off('jzsa:focus-index.jzsaGalleryButtonsMobile');
9908            $container.on('jzsa:focus-index.jzsaGalleryButtonsMobile', function(_e, targetIndex) {
9909                var $item = $container.find('[data-index="' + targetIndex + '"]').first();
9910                if ($item.length) {
9911                    revealItem($item);
9912                }
9913            });
9914
9915            galleryEl.addEventListener('touchstart', function(e) {
9916                var t = e.touches[0];
9917                var item = closestItem(e.target) || getItemAtClientPoint(t.clientX, t.clientY);
9918                if (!item) {
9919                    resetTouchTracking();
9920                    return;
9921                }
9922
9923                var $item = $(item);
9924                touchStartItem = $item;
9925                touchStartX = t.clientX;
9926                touchStartY = t.clientY;
9927
9928                var $currentActive = $container.find('.jzsa-item-touched').first();
9929                if ($currentActive.length && $currentActive[0] !== item) {
9930                    revealItem($item);
9931                }
9932            }, { passive: true });
9933
9934            galleryEl.addEventListener('touchmove', function(e) {
9935                var t = e.touches[0];
9936                if (touchStartX == null || touchStartY == null) {
9937                    return;
9938                }
9939
9940                if (
9941                    Math.abs(t.clientX - touchStartX) <= TOUCH_REVEAL_MOVE_TOLERANCE_PX &&
9942                    Math.abs(t.clientY - touchStartY) <= TOUCH_REVEAL_MOVE_TOLERANCE_PX
9943                ) {
9944                    return;
9945                }
9946
9947                if (touchStartItem && touchStartItem.length) {
9948                    revealItem(touchStartItem);
9949                }
9950            }, { passive: true });
9951
9952            galleryEl.addEventListener('touchend', function() {
9953                resetTouchTracking();
9954            }, { passive: true });
9955
9956            galleryEl.addEventListener('touchcancel', function() {
9957                resetTouchTracking();
9958            }, { passive: true });
9959        }());
9960
9961        $container.off('jzsa:exif-update.jzsaGalleryInfoRefresh');
9962        $container.on('jzsa:exif-update.jzsaGalleryInfoRefresh', function() {
9963            var latestPhotosJson = $container.attr('data-all-photos');
9964            if (latestPhotosJson) {
9965                try {
9966                    allPhotos = JSON.parse(latestPhotosJson);
9967                } catch (e) { /* ignore */ }
9968            }
9969            updateGalleryThumbnailInfoBoxes($container);
9970        });
9971
9972        // Wave 2: schedule background metadata prefetch if any zone uses
9973        // filename/name or EXIF placeholders.
9974        var galleryZoneFormats = readInfoZoneFormats($container);
9975        scheduleExifPrefetch($container, galleryZoneFormats);
9976
9977        jzsaDebug(
9978            '✅ Gallery initialized:',
9979            $container.attr('id'),
9980            '| layout:',
9981            layout,
9982            '| photos:',
9983            allPhotos.length,
9984            '| rows:',
9985            galleryRows > 0 ? galleryRows : 'all',
9986            '| scroll:',
9987            galleryScrollable ? 'true' : 'false',
9988            '| pages:',
9989            paginationState.totalPages
9990        );
9991    }
9992
9993    // ============================================================================
9994    // GALLERY INITIALIZATION
9995    // ============================================================================
9996
9997    function initializeGalleryElement(element, fallbackIndex) {
9998        var $gallery = $(element);
9999        if (!$gallery.length || isGalleryInitialized($gallery)) {
10000            return;
10001        }
10002
10003        ensureGalleryId($gallery, fallbackIndex);
10004
10005        var mode = $gallery.attr('data-mode') || 'gallery';
10006        if (mode === 'gallery') {
10007            initializeGallery(element);
10008        } else {
10009            initializeSwiper(element, mode);
10010        }
10011    }
10012
10013    function initializeAllGalleries() {
10014        var $galleries = $('.jzsa-album')
10015            .not('.jzsa-gallery-slideshow, .jzsa-gallery-controls')
10016            .filter(function() {
10017                return !isGalleryInitialized($(this));
10018            });
10019
10020        if (!$galleries.length) {
10021            return;
10022        }
10023
10024        $galleries.each(function(index) {
10025            ensureGalleryId($(this), index + 1);
10026        });
10027
10028        if (typeof IntersectionObserver === 'undefined') {
10029            $galleries.each(function(index) {
10030                initializeGalleryElement(this, index + 1);
10031            });
10032            return;
10033        }
10034
10035        if (!galleryInitObserver) {
10036            galleryInitObserver = new IntersectionObserver(function(entries) {
10037                entries.forEach(function(entry) {
10038                    if (!entry.isIntersecting) {
10039                        return;
10040                    }
10041
10042                    galleryInitObserver.unobserve(entry.target);
10043                    initializeGalleryElement(entry.target);
10044                });
10045            }, {
10046                rootMargin: '300px 0px',
10047                threshold: 0.01
10048            });
10049        }
10050
10051        $galleries.each(function() {
10052            galleryInitObserver.observe(this);
10053        });
10054    }
10055
10056    // Initialize when DOM is ready
10057    $(document).ready(function() {
10058        // Wait for Swiper library to load
10059        if (typeof Swiper !== 'undefined') {
10060            // console.log('✅ Swiper library found, initializing galleries...');
10061            initializeAllGalleries();
10062        } else {
10063            // console.error('❌ Swiper library not loaded!');
10064        }
10065    });
10066
10067    // Export for global access
10068    window.SharedGooglePhotos = {
10069        swipers: swipers,
10070        initialize: initializeSwiper,
10071        initializeGallery: initializeGallery,
10072        reinitialize: initializeAllGalleries
10073    };
10074
10075})(jQuery);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.