PageSourceSearch

https://collegehouses.org/wp-content/plugins/janzeman-shared-album…/js/swiper-init.js?ver=2.3.7.1783616670

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