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