PageSourceSearch

https://shereejwilson.com/airo-video-slots.js

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

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.