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