1/** 2 * airo-video-slots.js â Runtime script for video slot patching. 3 * 4 * Fetches /airo-media.json to discover which media slots have mediaType 'video', 5 * then uses a MutationObserver to replace <img> elements referencing those slots 6 * with <video> elements (autoplay, muted, loop, playsInline). Reconciles the 7 * reverse direction too, so a videoâimage replacement can't leave a blank area. 8 * 9 * Loaded synchronously in <head> so the observer is active before React hydrates. 10 * Works in both dev and production modes â in dev mode the Vite plugin also handles 11 * video patching via HMR, but this script acts as a fallback for direct page loads 12 * (e.g. opening the preview link in a new tab without the builder parent frame). 13 * 14 * Safari notes: 15 * - muted/playsinline must be HTML attributes (set before src) + explicit play() 16 * - z-index:-1 fill videos paint behind the host's opaque background â clear it 17 */ 18;(function () { 19 var SLOT_PREFIX_IMAGES = '/airo-assets/images/' 20 var SLOT_PREFIX_VIDEOS = '/airo-assets/videos/' 21 var BG_VIDEO_FILL_STYLE = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1;pointer-events:none;' 22 var CLEAR_BG_ATTR = 'data-airo-bg-video-clear-bg' 23 var mediaTypes = {} 24 var committedSlots = {} 25 26 function configureAutoplayVideo(video) { 27 // Keep in lockstep with dev-tools/src/utils/autoplay-video.ts (parity test). 28 video.muted = true 29 video.defaultMuted = true 30 video.autoplay = true 31 video.loop = true 32 video.playsInline = true 33 video.preload = 'auto' 34 video.setAttribute('muted', '') 35 video.setAttribute('playsinline', '') 36 video.setAttribute('webkit-playsinline', '') 37 var kick = function () { 38 var p = video.play() 39 if (p && typeof p.catch === 'function') p.catch(function () {}) 40 } 41 if (video.isConnected) { 42 // HAVE_CURRENT_DATA === 2 â mirror TS readyState gate 43 if (video.readyState >= 2) { 44 kick() 45 } else { 46 video.addEventListener('loadeddata', kick, { once: true }) 47 // Also try immediately â Safari sometimes needs the call before loadeddata 48 kick() 49 } 50 } else if (typeof requestAnimationFrame === 'function') { 51 requestAnimationFrame(kick) 52 } else { 53 kick() 54 } 55 } 56 57 function prepareBackgroundVideoHost(el) { 58 var pos = window.getComputedStyle(el).position 59 if (pos === 'static') el.style.position = 'relative' 60 el.style.setProperty('background-color', 'transparent', 'important') 61 el.setAttribute(CLEAR_BG_ATTR, 'true') 62 } 63
64 function restoreBackgroundVideoHost(el) { 65 if (!el.getAttribute(CLEAR_BG_ATTR)) return 66 el.style.removeProperty('background-color') 67 el.removeAttribute(CLEAR_BG_ATTR) 68 } 69 70 // Requires the slot to be present in the manifest: an absent entry means 71 // "unknown", not "image", and must never downgrade a video to an <img>. 72 // Relies on mediaType being required on every slot write (@airo/shared MediaSlot). 73 // A slot missing mediaType is skipped at ingest below, so it stays "unknown" and 74 // a source-authored <video> for it is left alone rather than swapped. 75 function isImageSlot(slotPath) { 76 return !!slotPath 77 && Object.prototype.hasOwnProperty.call(mediaTypes, slotPath) 78 && mediaTypes[slotPath] !== 'video' 79 } 80 81 function isVideoSlot(slotPath) { 82 return !!slotPath && mediaTypes[slotPath] === 'video' 83 } 84 85 function extractSlotPath(url) { 86 if (!url) return null 87 var prefixes = [SLOT_PREFIX_IMAGES, SLOT_PREFIX_VIDEOS] 88 for (var i = 0; i < prefixes.length; i++) { 89 var idx = url.indexOf(prefixes[i]) 90 if (idx !== -1) { 91 var after = url.substring(idx + prefixes[i].length) 92 return after.split('?')[0] 93 } 94 } 95 return null 96 } 97 98 function patchImg(img) { 99 if (!img.src) return 100 var slotPath = extractSlotPath(img.src) 101 102 if (img.getAttribute('data-airo-video-patched')) { 103 // Slot reverted to image â reveal the img and drop the injected video. 104 // Also covers the orphan case where React already removed the video. 105 if (isImageSlot(slotPath)) { 106 var stale = img.parentNode && img.parentNode.querySelector('video[data-slot="' + slotPath + '"]') 107 if (stale) stale.remove() 108 img.removeAttribute('data-airo-video-patched') 109 img.style.display = '' 110 } 111 return 112 } 113 114 if (!isVideoSlot(slotPath)) return 115 116 // Remove any existing video for this slot to prevent duplicates after re-renders 117 var existing = img.parentNode && img.parentNode.querySelector('video[data-slot="' + slotPath + '"]') 118 if (existing) existing.remove() 119 120 var videoUrl = img.src.replace(SLOT_PREFIX_IMAGES, SLOT_PREFIX_VIDEOS) 121 var video = document.createElement('video') 122 video.className = img.className 123 video.style.cssText = img.style.cssText 124 if (img.width) video.width = img.width 125 if (img.height) video.height = img.height 126 video.setAttribute('data-airo-video', '') 127 video.setAttribute('data-slot', slotPath) 128 configureAutoplayVideo(video) 129 video.src = videoUrl 130 131 img.setAttribute('data-airo-video-patched', 'true') 132 img.style.display = 'none' 133 if (img.parentNode) { 134 img.parentNode.insertBefore(video, img.nextSibling) 135 } 136 } 137 138 function patchBgElement(el) { 139 var bgImage = window.getComputedStyle(el).backgroundImage 140 if (!bgImage || bgImage === 'none') return 141 if (bgImage.indexOf(SLOT_PREFIX_IMAGES) === -1 && bgImage.indexOf(SLOT_PREFIX_VIDEOS) === -1) return 142 var urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/) 143 if (!urlMatch || !urlMatch[1]) return 144 var slotPath = extractSlotPath(urlMatch[1]) 145 if (!isVideoSlot(slotPath)) return 146 147 // If already patched and video exists, just re-hide background (React may have restored it) 148 if (el.getAttribute('data-airo-video-bg-patched') === slotPath) { 149 el.style.backgroundImage = 'none' 150 return 151 } 152 153 // Remove any existing bg video to prevent duplicates 154 var existing = el.querySelector('video[data-airo-bg-video]') 155 if (existing) existing.remove() 156 157 el.style.backgroundImage = 'none' 158 el.setAttribute('data-airo-video-bg-patched', slotPath) 159 var videoUrl = urlMatch[1].replace(SLOT_PREFIX_IMAGES, SLOT_PREFIX_VIDEOS) 160 if (videoUrl.indexOf(SLOT_PREFIX_VIDEOS) === -1) { 161 videoUrl = SLOT_PREFIX_VIDEOS + slotPath 162 } 163 var video = document.createElement('video') 164 video.setAttribute('data-airo-bg-video', '') 165 video.setAttribute('data-slot', slotPath) 166 video.style.cssText = BG_VIDEO_FILL_STYLE 167 configureAutoplayVideo(video) 168 video.src = videoUrl 169 prepareBackgroundVideoHost(el) 170 el.insertBefore(video, el.firstChild) 171 } 172
173 function unpatchVideo(video) { 174 if (video.hasAttribute('data-airo-bg-video')) return 175 // dev-tools owns its provisional preview nodes (data-airo-media-preview) 176 if (video.hasAttribute('data-airo-media-preview')) return 177 var slotPath = video.getAttribute('data-slot') || extractSlotPath(video.src) 178 if (!isImageSlot(slotPath)) return 179 180 var prevImg = video.previousElementSibling 181 var patchedImg = prevImg && prevImg.tagName === 'IMG' && prevImg.getAttribute('data-airo-video-patched') 182 ? prevImg 183 : null 184 185 if (patchedImg || video.hasAttribute('data-airo-video')) { 186 if (patchedImg) { 187 patchedImg.removeAttribute('data-airo-video-patched') 188 patchedImg.style.display = '' 189 } 190 video.remove() 191 return 192 } 193 194 // Source declares <video> for a slot that now holds an image â swap in an 195 // <img> so the asset renders instead of leaving an empty video box. 196 var img = document.createElement('img') 197 img.src = SLOT_PREFIX_IMAGES + slotPath 198 img.className = video.className 199 img.style.cssText = video.style.cssText 200 img.alt = video.getAttribute('aria-label') || '' 201 if (video.parentNode) video.parentNode.replaceChild(img, video) 202 } 203 204 function unpatchBgElement(el) { 205 var slotPath = el.getAttribute('data-airo-video-bg-patched') 206 if (!isImageSlot(slotPath)) return 207 var bgVideo = el.querySelector('video[data-airo-bg-video]') 208 if (bgVideo) bgVideo.remove() 209 el.removeAttribute('data-airo-video-bg-patched')
210 restoreBackgroundVideoHost(el) 211 el.style.backgroundImage = 'url(' + JSON.stringify(SLOT_PREFIX_IMAGES + slotPath) + ')' 212 } 213 214 function patchAll() { 215 document.querySelectorAll('video').forEach(unpatchVideo) 216 document.querySelectorAll('[data-airo-video-bg-patched]').forEach(unpatchBgElement) 217 document.querySelectorAll('img').forEach(patchImg) 218 // Check elements with inline background styles or already-patched elements 219 document.querySelectorAll('[style*="background"], [data-airo-video-bg-patched]').forEach(patchBgElement) 220 // Also scan all elements for CSS-class-based background-images referencing our slot prefixes. 221 // patchBgElement uses getComputedStyle internally and bails early if no matching URL is found, 222 // so this is safe but slightly more expensive â only run when we have video slots. 223 var hasVideoSlots = false 224 for (var k in mediaTypes) { 225 if (isVideoSlot(k)) { hasVideoSlots = true; break } 226 } 227 if (hasVideoSlots) { 228 document.querySelectorAll('section, div, header, main, [class*="hero"], [class*="banner"], [class*="background"]').forEach(function (el) { 229 if (!el.getAttribute('data-airo-video-bg-patched') && !el.hasAttribute('style')) { 230 patchBgElement(el) 231 } 232 }) 233 } 234 } 235 236 window.airoSetMediaSlotType = function (slotPath, mediaType) { 237 if (slotPath && mediaType) { 238 mediaTypes[slotPath] = mediaType // dev-tools commits a type after our load-time manifest read; without this the next observer pass reverts its swap 239 committedSlots[slotPath] = mediaType 240 } 241 } 242 243 // Both manifest ingest sites route through here. A dev-tools commit outranks manifest 244 // responses that disagree with it â the in-flight initial fetch, or a poll that has not 245 // caught up â but only until the manifest agrees, after which the slot rejoins normal 246 // synchronization so a later change of kind is not ignored for the rest of the session. 247 function ingestManifestType(slotPath, mediaType) { 248 if (committedSlots[slotPath]) { 249 if (committedSlots[slotPath] !== mediaType) return false 250 delete committedSlots[slotPath] 251 } 252 if (mediaTypes[slotPath] === mediaType) return false 253 mediaTypes[slotPath] = mediaType 254 return true 255 } 256 257 // Fetch manifest then start observing 258 fetch('/airo-media.json') 259 .then(function (res) { 260 if (!res.ok) return {} 261 return res.json() 262 }) 263 .then(function (manifest) { 264 for (var key in manifest) { 265 if (manifest[key] && manifest[key].mediaType) { 266 ingestManifestType(key, manifest[key].mediaType) 267 } 268 } 269 // Patch existing images 270 patchAll() 271 // Observe future DOM changes â childList for new nodes, attributes for style changes 272 var isPatching = false 273 var observer = new MutationObserver(function (mutations) { 274 if (isPatching) return 275 isPatching = true 276 try { 277 for (var i = 0; i < mutations.length; i++) { 278 var mutation = mutations[i] 279 if (mutation.type === 'childList') { 280 var added = mutation.addedNodes 281 for (var j = 0; j < added.length; j++) { 282 var node = added[j] 283 if (node instanceof HTMLImageElement) { 284 patchImg(node) 285 } else if (node instanceof HTMLVideoElement) { 286 unpatchVideo(node) 287 } else if (node instanceof HTMLElement) { 288 node.querySelectorAll('video').forEach(unpatchVideo) 289 node.querySelectorAll('img').forEach(patchImg) 290 // Check added elements for background-image video slots 291 patchBgElement(node) 292 node.querySelectorAll('[style*="background"]').forEach(patchBgElement) 293 } 294 } 295 } else if (mutation.type === 'attributes' && mutation.attributeName === 'style') { 296 var target = mutation.target 297 if (target instanceof HTMLElement) { 298 // Re-check: React may have re-rendered and set background-image back 299 // Remove stale patch flag so patchBgElement re-evaluates 300 if (target.getAttribute('data-airo-video-bg-patched')) { 301 var bg = target.style.backgroundImage 302 if (bg && bg !== 'none' && (bg.indexOf(SLOT_PREFIX_IMAGES) !== -1 || bg.indexOf(SLOT_PREFIX_VIDEOS) !== -1)) { 303 target.removeAttribute('data-airo-video-bg-patched') 304 patchBgElement(target) 305 } 306 } else { 307 patchBgElement(target) 308 } 309 } 310 } 311 } 312 } finally { 313 isPatching = false 314 } 315 }) 316 observer.observe(document.documentElement, { 317 childList: true, 318 subtree: true, 319 attributes: true, 320 attributeFilter: ['style'] 321 }) 322 323 // Re-fetch manifest periodically to pick up changes (dev mode only â HMR may not work due to CORS) 324 // Use __AIRO_DEV_MODE__ flag set by dev-supervisor, falling back to localhost check 325 var isDevMode = window.__AIRO_DEV_MODE__ === true || location.hostname === 'localhost' || location.hostname === '127.0.0.1' 326 if (isDevMode) { 327 var pollFailures = 0 328 var pollIntervalId = setInterval(function () { 329 fetch('/airo-media.json').then(function (r) { 330 if (!r.ok) return {} 331 return r.json() 332 }).then(function (m) { 333 pollFailures = 0 334 var changed = false 335 for (var k in m) { 336 if (m[k] && m[k].mediaType && ingestManifestType(k, m[k].mediaType)) { 337 changed = true 338 } 339 }
340 if (changed) patchAll() 341 }).catch(function (err) { 342 pollFailures++ 343 if (pollFailures === 1) { 344 // eslint-disable-next-line no-console 345 console.warn('[airo-video-slots] manifest poll failed:', err.message || err) 346 } 347 if (pollFailures >= 5) { 348 clearInterval(pollIntervalId) 349 } 350 }) 351 }, 3000) 352 } 353 }) 354 .catch(function () { 355 // Manifest not available â no video slots to patch 356 }) 357})()
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.