PageSourceSearch

https://greenhealth.org/airo-video-slots.js

js greenhealth.org collected 2026-09-26 18:46:10 UTC 14,426 bytes, 357 lines download raw bytes

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.