1/** 2 * GBF video modal â single, framework-independent handler. 3 * 4 * Owns every YouTube trigger on the site (podcast cards, partner videos, 5 * webflow .play lightboxes, anything with a data-video attribute). It opens 6 * and closes the #videoModal WITHOUT depending on Bootstrap's modal plugin so 7 * behaviour is identical on local and production, and it ALWAYS clears the 8 * iframe src on close so audio/video never keeps playing in the background. 9 * 10 * Trigger clicks are caught in the capture phase and stopped there, which also 11 * disables Bootstrap's competing data-toggle="modal" data-api auto-open. 12 */ 13(function() { 14 if (window.GBFVideoModalHandlerLoaded) { 15 return; 16 } 17 18 window.GBFVideoModalHandlerLoaded = true; 19 // Legacy flags other scripts checked, kept so nothing re-binds. 20 window.GBFVideoModalFallbackEnhancedLoaded = true; 21 window.GBFVideoModalFallbackLoaded = true; 22 23 var triggerSelector = '.partner-video, .jedburghpodcast_vid, .gbf-video-trigger, .play, .playcontainer, .playcontainersub, [data-video], [data-video-id], [data-video-url]'; 24 var containerSelector = '.playcontainer, .playcontainersub, .play, .gbf-video-trigger, .partner-video, .jedburghpodcast_vid'; 25 26 function hasInteractiveParent(node) { 27 return !!(node && node.closest && node.closest('a, button')); 28 } 29 30 function getAttributeVideo(node) { 31 if (!node || !node.getAttribute) { 32 return ''; 33 } 34 35 return node.getAttribute('data-video') || 36 node.getAttribute('data-video-id') || 37 node.getAttribute('data-video-url') || 38 ''; 39 } 40 41 function getYouTubeId(value) { 42 var rawValue = String(value || '').trim(); 43 var srcMatch; 44 var parsedUrl; 45 var queryId; 46 var pathMatch; 47 var fallbackMatch; 48 49 if (!rawValue) { 50 return ''; 51 } 52 53 if (/^[A-Za-z0-9_-]{11}$/.test(rawValue)) { 54 return rawValue; 55 } 56 57 srcMatch = rawValue.match(/\bsrc=["']([^"']+)["']/i); 58 if (srcMatch) { 59 return getYouTubeId(srcMatch[1]); 60 } 61 62 try { 63 parsedUrl = new URL(rawValue, window.location.href); 64 queryId = parsedUrl.searchParams.get('v'); 65 66 if (queryId && /^[A-Za-z0-9_-]{11}$/.test(queryId)) { 67 return queryId; 68 } 69 70 pathMatch = parsedUrl.pathname.match(/(?:embed|shorts|live)\/([A-Za-z0-9_-]{11})/); 71 72 if (pathMatch) { 73 return pathMatch[1]; 74 } 75 76 if (parsedUrl.hostname.indexOf('youtu.be') !== -1) { 77 pathMatch = parsedUrl.pathname.replace(/^\//, '').match(/^([A-Za-z0-9_-]{11})/); 78 79 if (pathMatch) { 80 return pathMatch[1]; 81 } 82 } 83 } catch (error) { 84 // Fall through to regex extraction for encoded Embedly/Webflow payloads. 85 } 86 87 fallbackMatch = rawValue.match(/(?:v=|embed\/|embed%2F|shorts\/|shorts%2F|live\/|live%2F|youtu\.be\/|youtu\.be%2F)([A-Za-z0-9_-]{11})/i); 88 89 if (fallbackMatch) { 90 return fallbackMatch[1]; 91 } 92 93 if ((rawValue.indexOf('youtube') !== -1 || rawValue.indexOf('youtu.be') !== -1) && rawValue.match(/[A-Za-z0-9_-]{11}/)) { 94 return rawValue.match(/[A-Za-z0-9_-]{11}/)[0]; 95 } 96 97 return ''; 98 } 99 100 function getLegacyPlayVideo(trigger) { 101 var scope = trigger; 102 var jsonNode; 103 var lightboxData; 104 var firstItem; 105 106 if (!scope) { 107 return ''; 108 } 109 110 jsonNode = scope.querySelector ? scope.querySelector('.w-json') : null; 111 112 if (!jsonNode && scope.closest) { 113 scope = scope.closest(containerSelector); 114 jsonNode = scope && scope.querySelector ? scope.querySelector('.w-json') : null; 115 } 116 117 if (!jsonNode) { 118 return ''; 119 } 120 121 try { 122 lightboxData = JSON.parse(jsonNode.textContent || '{}'); 123 firstItem = lightboxData.items && lightboxData.items[0]; 124 125 return firstItem ? (firstItem.originalUrl || firstItem.url || firstItem.html || firstItem.thumbnailUrl || '') : ''; 126 } catch (error) { 127 return ''; 128 } 129 } 130 131 function getTriggerVideoValue(trigger) { 132 var videoValue = getAttributeVideo(trigger); 133 var parentWithVideo; 134 var childWithVideo; 135 136 if (videoValue) { 137 return videoValue; 138 } 139 140 parentWithVideo = trigger && trigger.closest ? trigger.closest('[data-vi
140deo], [data-video-id], [data-video-url]') : null; 141 videoValue = getAttributeVideo(parentWithVideo); 142 143 if (videoValue) { 144 return videoValue; 145 } 146 147 childWithVideo = trigger && trigger.querySelector ? trigger.querySelector('[data-video], [data-video-id], [data-video-url]') : null; 148 videoValue = getAttributeVideo(childWithVideo); 149 150 if (videoValue) { 151 return videoValue; 152 } 153 154 return getLegacyPlayVideo(trigger); 155 } 156 157 function ensureBackdrop() { 158 var backdrop; 159 160 if (document.querySelector('.modal-backdrop.gbf-video-backdrop')) { 161 return; 162 } 163 164 backdrop = document.createElement('div'); 165 backdrop.className = 'modal-backdrop fade show gbf-video-backdrop'; 166 document.body.appendChild(backdrop); 167 } 168 169 function clearBackdrops() { 170 document.querySelectorAll('.modal-backdrop.gbf-video-backdrop').forEach(function(backdrop) { 171 if (backdrop.parentNode) { 172 backdrop.parentNode.removeChild(backdrop); 173 } 174 }); 175 } 176 177 function ensureVideoModal() { 178 var modal = document.getElementById('videoModal'); 179 var modalBody; 180 var iframe; 181 182 if (!modal) { 183 modal = document.createElement('div'); 184 modal.className = 'modal fade video-modal'; 185 modal.id = 'videoModal'; 186 modal.tabIndex = -1; 187 modal.setAttribute('aria-hidden', 'true'); 188 modal.innerHTML = 189 '<button type="button" class="close" data-dismiss="modal" aria-label="Close">' + 190 '<span aria-hidden="true">×</span>' + 191 '</button>' + 192 '<div class="modal-dialog modal-dialog-centered modal-xl">' + 193 '<div class="modal-content">' + 194 '<div class="modal-body"></div>' + 195 '</div>' + 196 '</div>'; 197 198 document.body.appendChild(modal); 199 } 200 201 iframe = modal.querySelector('iframe'); 202 203 if (!iframe) { 204 modalBody = modal.querySelector('.modal-body') || modal; 205 iframe = document.createElement('iframe'); 206 iframe.width = '560'; 207 iframe.height = '315'; 208 iframe.title = 'YouTube video player'; 209 iframe.frameBorder = '0'; 210 iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'; 211 iframe.referrerPolicy = 'strict-origin-when-cross-origin'; 212 iframe.allowFullscreen = true; 213 modalBody.appendChild(iframe); 214 } 215 216 return modal; 217 } 218 219 function isModalOpen() { 220 var modal = document.getElementById('videoModal'); 221 return !!(modal && modal.classList.contains('show')); 222 } 223 224 function openVideoModal(videoValue) { 225 var videoId = getYouTubeId(videoValue); 226 var modal; 227 var iframe; 228 229 if (!videoId) { 230 return false; 231 } 232 233 modal = ensureVideoModal(); 234 iframe = modal.querySelector('iframe'); 235 236 if (iframe) { 237 iframe.setAttribute('src', 'https://www.youtube.com/embed/' + encodeURIComponent(videoId) + '?autoplay=1&rel=0'); 238 } 239 240 modal.style.display = 'block'; 241 modal.removeAttribute('aria-hidden'); 242 modal.setAttribute('aria-modal', 'true'); 243 modal.classList.add('show'); 244 document.body.classList.add('modal-open'); 245 ensureBackdrop(); 246 247 return true; 248 } 249 250 function closeVideoModal() { 251 var modal = document.getElementById('videoModal'); 252 var iframe; 253 254 if (!modal) { 255 return; 256 } 257 258 modal.classList.remove('show'); 259 modal.style.display = 'none'; 260 modal.setAttribute('aria-hidden', 'true'); 261 modal.removeAttribute('aria-modal'); 262 document.body.classList.remove('modal-open'); 263 264 // Always stop playback by tearing down the iframe src. 265 iframe = modal.querySelector('iframe'); 266 if (iframe) { 267 iframe.setAttribute('src', ''); 268 } 269 270 clearBackdrops(); 271 } 272 273 function handleTrigger(event) { 274 var trigger = event.target.closest(triggerSelector); 275 var videoValue; 276 277 if (!trigger) { 278 return; 279 } 280
281 videoValue = getTriggerVideoValue(trigger); 282 283 if (!getYouTubeId(videoValue)) { 284 return; 285 } 286 287 // Capture-phase stop: prevents Bootstrap's data-toggle data-api (and any 288 // other delegated handler) from also acting on this same click. 289 event.preventDefault(); 290 event.stopPropagation(); 291 openVideoModal(videoValue); 292 } 293 294 // Open â capture phase so we win over Bootstrap's bubble-phase data-api. 295 document.addEventListener('click', handleTrigger, true); 296 297 document.addEventListener('keydown', function(event) { 298 var trigger; 299 var videoValue; 300 301 if (event.key !== 'Enter' && event.key !== ' ') { 302 return; 303 } 304 305 trigger = event.target.closest(triggerSelector); 306 307 // Native <a>/<button> triggers already emit a click on Enter/Space, which 308 // the click handler above catches. Only handle non-interactive triggers 309 // (e.g. div.play with role="button") here to avoid double-opening. 310 if (!trigger || trigger.matches('a, button')) { 311 return; 312 } 313 314 videoValue = getTriggerVideoValue(trigger); 315 316 if (!getYouTubeId(videoValue)) { 317 return; 318 } 319 320 event.preventDefault(); 321 openVideoModal(videoValue); 322 }, true); 323 324 // Close â close button, [data-dismiss], the dark backdrop, or a click on the 325 // modal container itself (the area outside the centered dialog). 326 document.addEventListener('click', function(event) { 327 var modal = document.getElementById('videoModal'); 328 329 if (!modal || !isModalOpen()) { 330 return; 331 } 332 333 var isDismiss = !!event.target.closest('#videoModal .close, #videoModal [data-dismiss="modal"], .modal-backdrop.gbf-video-backdrop'); 334 var isOutside = event.target === modal; // clicked the container, not the dialog 335 336 if (!isDismiss && !isOutside) { 337 return; 338 } 339 340 event.preventDefault(); 341 closeVideoModal(); 342 }); 343 344 // Close on Escape. 345 document.addEventListener('keydown', function(event) { 346 if ((event.key === 'Escape' || event.key === 'Esc') && isModalOpen()) { 347 closeVideoModal(); 348 } 349 }); 350 351 // If anything still drives the modal through Bootstrap, clean up on hide too. 352 document.addEventListener('hidden.bs.modal', function(event) { 353 if (event.target && event.target.id === 'videoModal') { 354 closeVideoModal(); 355 } 356 }); 357 358 document.addEventListener('DOMContentLoaded', function() { 359 document.querySelectorAll('.gbf-video-trigger, .play, .playcontainer, .playcontainersub').forEach(function(trigger) { 360 if (!getYouTubeId(getTriggerVideoValue(trigger)) || hasInteractiveParent(trigger)) { 361 return; 362 } 363 364 trigger.setAttribute('role', 'button'); 365 366 if (!trigger.hasAttribute('tabindex')) { 367 trigger.setAttribute('tabindex', '0'); 368 } 369 }); 370 }); 371})();
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.