PageSourceSearch

https://storytellers-twcfi.netlify.app/ar-boot.js

js storytellers-twcfi.netlify.app collected 2026-10-03 11:27:36 UTC 26,236 bytes, 559 lines download raw bytes

1/* globals XR8 */
2// 8th Wall engine wiring. This is the ONLY tracking logic in the app: the A-Frame scene,
3// the <hologram-4ds> player and the overlay DOM all stay tracking-agnostic, and
4// world-anchor.js bridges tracking onto the targetFound contract that hologram-4ds.js
5// already listens for.
6//
7// Replaces MindAR:
8//   mindar-image="imageTargetSrc: assets/targets.mind"  ->  XR8.XrController.configure({imageTargetData})
9//   scene 'arReady'                                     ->  scene 'realityready'
10//   scene 'arError'                                     ->  scene 'realityerror'   (NOT 'xrerror';
11//                                                           that name only exists on the Sumerian
12//                                                           and PlayCanvas bindings and never fires here)
13//   mindar-image-target targetFound/targetLost          ->  world-anchor.js
14//
15// The xrweb component is attached from HERE rather than from the <a-scene> markup. xr.js
16// registers xrweb/xrconfig from inside its own async boot chain, and A-Frame does NOT
17// retro-initialise components that were registered after the scene finished loading, so
18// putting xrweb in the markup is a race (symptom: "Unknown component: xrweb"). Attaching
19// on 'xrloaded' is deterministic, and it also lets us choose 6DoF vs 3DoF *before*
20// XR8.run(), which is the only time the engine will accept that choice.
21//
22// xrweb's schema is the shared config schema plus the world-tracking keys, and its init
23// forwards the shared keys onto an xrconfig component it adds itself. So everything goes
24// on the one setAttribute and we never touch xrconfig directly.
25
26;(function () {
27  // Which of the nine experiences this page is: a storyteller and one of their three takes.
28  // Set by an inline script in the page (the page's ONLY per-page line), resolved against
29  // experiences.js, which merges the take's scan URL with the storyteller's assets.
30  //
31  // Every page loads ALL THREE image targets, this experience's own first. Only its own ever
32  // places the scan: world-anchor.js ignores any other name. The other two are loaded so the
33  // page can recognise a visitor holding the wrong card and send them to the right page (see
34  // the wrong-marker redirect below). A storyteller's three takes all share that
35  // storyteller's one marker.
36  const EXPERIENCE = window.currentExperience && window.currentExperience()
37  if (!EXPERIENCE) {
38    console.error('[ar] no experience for this page. Is EXPERIENCE_ID set before experiences.js?')
39    return
40  }
41  const IMAGE_TARGET = EXPERIENCE.target
42  const ALL_TARGETS = [
43    IMAGE_TARGET,
44    ...window.STORYTELLER_ORDER
45      .map((s) => window.STORYTELLERS[s].target)
46      .filter((t) => t && t.name !== IMAGE_TARGET.name),
47  ]
48  // Guarded rather than EXPERIENCE.label.title: nine config entries is nine chances to leave
49  // a label off, and a missing one should not take the whole boot down before the engine
50  // has had a chance to report anything more useful.
51  const TITLE = (EXPERIENCE.label && EXPERIENCE.label.title) || EXPERIENCE.storyteller || '?'
52  console.log(
53    `[ar] experience: ${TITLE} ${EXPERIENCE.take} (${EXPERIENCE.id}) | image target:`,
54    IMAGE_TARGET.name
55  )
56
57  // How long to wait for 'realityready' after releasing the camera before deciding the
58  // engine is not going to start and dropping to the 3DoF fallback.
59  const REALITY_TIMEOUT_MS = 12000
60
61  const scene = document.querySelector('a-scene')
62  const introOverlay = document.getElementById('intro-overlay')
63  const continueBtn = document.getElementById('continue-button')
64  const errorOverlay = document.getElementById('camera-error-overlay')
65  const errorTitle = document.getElementById('camera-error-title')
66  const errorText = document.getElementById('camera-error-text')
67  const retryBtn = document.getElementById('camera-retry-button')
68  const uiContainer = document.getElementById('ui-container')
69  const scanPrompt = document.getElementById('scan-prompt')
70
71  let mode = 'unknown'      // '6dof' (SLAM) | '3dof' (gyro only, the Path C fallback)
72  let engineAttached = false
73  let continueTapped = false
74  let running = false
75  let realityReady = false
76  let watchdog = null
77  // Backgrounding state, tracked separately for the scan and the narration: they are
78  // independent timelines now, and only whichever was actually running gets resumed.
79  let scanPlayingOnHide = false
80  let narrationPlayingOnHide = false
81
82  // The prompt copy to restore when tracking recovers. Starts as whatever the page
83  // authored, and is replaced by the 3DoF wording if we end up in that mode. Held as
84  // markup, not text, because the authored copy is two lines separated by a <br>: reading
85  // it back as textContent and restoring that would silently collapse it to one line.
86  // Only ever set from copy authored in this repo.
87  let defaultScanPrompt = scanPrompt ? scanPrompt.innerHTML : ''
88
89  // The 4DViews player instance, once the holo4ds component has built it.
90  const holoModel = () => {
91    const el = document.querySelector('[holo4ds]')
92    const c = el && el.components && el.components.holo4ds
93    return c && c.model4DS
94  }
95
96  // Whether world-anchor.js has placed the scan yet.
97  const anchorPlaced = () => {
98    const el = document.getElementById('world-anchor')
99    const c = el && el.components && el.components['world-anchor']
100    return !!(c && c.placed)
101  }
102
103  const showError = (title, text) => {
104    if (title && errorTitle) errorTitle.textContent = title
105    if (text && errorText) errorText.textContent = text
106    if (errorOverlay) errorOverlay.classList.remove('hidden')
107  }
108
109  // ---- engine configuration -------------------------------------------------
110
111  const attachEngine = (worldTracking) => {
112    mode = worldTracking ? '6dof' : '3dof'
113    scene.setAttribute('xrweb', {
114      // allowedDevices MUST be 'any'. On a device the engine judges incompatible it
115      // short-circuits xrconfig.init, ignores delayRun and starts the camera immediately,
116      // which is exactly the case where we want the Continue gate to still hold.
117      allowedDevices: 'any',
118      delayRun: true,
119      disableXrTablet: true,
120      disableDefaultEnvironment: true,
121      disableWorldTracking: !worldTracking,
122      // 'absolute' (metric) scale is only legal with world tracking on: the engine throws
123      // "[XR] Unable to perform metric scale estimation if world tracking is disabled".
124      // 'responsive' is the one setting that works identically in 6DoF and 3DoF.
125      scale: 'responsive',
126    })
127    XR8.XrController.configure({imageTargetData: ALL_TARGETS})
128    engineAttached = true
129    console.log('[ar] engine attached, mode =', mode)
130    maybeStart()
131  }
132
133  const chooseMode = () => {
134    if (sessionStorage.getItem('ar-force-3dof')) {
135      console.warn('[ar] ar-force-3dof is set, starting in 3DoF')
136      attachEngine(false)
137      return
138    }
139    // Mirror the engine's own internal gate: it refuses world tracking with a camera on
140    // anything it does not consider mobile ("Reality with camera on non-mobile devices
141    // requires disableWorldTracking"). Probing first turns that throw into a clean
142    // fallback, and incidentally makes desktop work as a development harness.
143    const canSlam = XR8.XrDevice.isDeviceBrowserCompatible({
144      allowedDevices: XR8.XrConfig.device().MOBILE,
145    })
146    if (!canSlam) console.warn('[ar] device is not SLAM-capable:', XR8.XrDevice.deviceEstimate())
147    attachEngine(canSlam)
148  }
149
150  // Writes this experience onto the scene, then applies any tuning overrides from the URL.
151  //
152  // Two values used to be literal in each page's markup: the scan URL on <hologram-4ds
153  // main-4ds> and the marker name on <a-entity world-anchor="name: ...">. At three pages
154  // that was tolerable; at nine it is a drift trap, and the anchor's name was always a
155  // duplicate of EXPERIENCES[id].target.name with nothing keeping the two honest. Both now
156  // come from experiences.js, which leaves EXPERIENCE_ID as the only per-page line in the
157  // markup and makes the nine pages diffable against each other.
158  //
159  // Both writes are safe HERE specifically, and would not be from a deferred script:
160  //
161  //   main-4ds  is read in hologram-4ds.js's setupHologram(), which init() defers to the
162  //             scene's 'loaded' event whenever the scene has not loaded yet. This function
163  //             runs at parse time (see the call site below), long before that -- the same
164  //             guarantee the `size` override underneath has always relied on.
165  //   name      is read by world-anchor.js inside its xrimage* handlers at call time, not in
166  //             init(), so it is safe whenever it is set.
167  //
168  // secondary-4ds (the 4DViews fire demo shown on non-ASTC desktops) stays in the markup: it
169  // is the same on all nine pages and is not per-experience.
170  const applyExperience = () => {
171    const holo = document.querySelector('hologram-4ds')
172    const anchor = document.getElementById('world-anchor')
173    if (holo && EXPERIENCE.scan) holo.setAttribute('main-4ds', EXPERIENCE.scan)
174    if (anchor && IMAGE_TARGET.name) anchor.setAttribute('world-anchor', `name: ${IMAGE_TARGET.name}`)
175    // Optional per-take transform, for the case where one scan needs its own placement. No
176    // take defines one today; all nine use the values in the markup. See experiences.js.
177    const t = EXPERIENCE.transform
178    if (holo && t) {
179      if (t.position) holo.setAttribute('position', t.position)
180      if (t.rotation) holo.setAttribute('rotation', t.rotation)
181      if (t.size !== undefined) holo.setAttribute('size', t.size)
182      console.log('[ar] per-take transform from config:', JSON.stringify(t))
183    }
184    console.log('[ar] scan:', EXPERIENCE.scan)
185    applyTransformOverrides()
186  }
187
188  // Query-string override for the hologram's transform, so it can be tuned against the
189  // printed marker on a phone without a console and without an edit-deploy cycle per
190  // attempt. Absent params change nothing, so this is inert in production.
191  //
192  //   ?pos=0,1,0   entity position, in anchor units (multiplied by the target's scale)
193  //   ?rot=90,0,0  entity rotation in degrees. 90 0 0 stands the scan out of the marker.
194  //   ?size=0.8    uniform scale, also multiplied by the target's scale
195  //
196  // The HASH form works too, and is the safer one to type on a phone:
197  //
198  //   #pos=0,1,0&size=0.65
199  //
200  // Hosts that rewrite to clean URLs (including `npx serve`, the local harness) 301
201  // /antoinette-2.html -> /antoinette-2 and DROP the query string doing it, so ?size= typed
202  // against the .html URL silently does nothing and tuning looks broken. A hash survives
203  // any redirect. Query wins when both are present.
204  //
205  // Deliberately not surfaced in the UI, per the no-on-screen-hints rule. Whatever reads
206  // right on device gets written back as the new default -- into the markup if it is right
207  // for all nine, or as a `transform` on that one take in experiences.js if it is not. Do
208  // not edit a single page: the nine are meant to stay identical.
209  const applyTransformOverrides = () => {
210    const search = new URLSearchParams(location.search)
211    const hash = new URLSearchParams(location.hash.slice(1))
212    const q = {
213      get: (k) => (search.get(k) !== null ? search.get(k) : hash.get(k)),
214      has: (k) => search.has(k) || hash.has(k),
215    }
216    const holo = document.querySelector('hologram-4ds')
217    if (!holo) return
218    const triple = /^-?[\d.]+,-?[\d.]+,-?[\d.]+$/
219    ;[['pos', 'position'], ['rot', 'rotation']].forEach(([param, attr]) => {
220      const v = q.get(param)
221      if (v && triple.test(v)) holo.setAttribute(attr, v.split(',').join(' '))
222    })
223    // `size` is read by holo4ds at setup and written to `scale` once the sequence loads,
224    // so overriding the attribute is enough: no need to touch scale directly.
225    const size = q.get('size')
226    if (size && !isNaN(parseFloat(size))) holo.setAttribute('size', size)
227    if (q.has('pos') || q.has('rot') || q.has('size')) {
228      // getAttribute returns parsed vec3 objects, so stringify for a readable line.
229      const v = (n) => AFRAME.utils.coordinates.stringify(holo.getAttribute(n))
230      console.log(`[ar] transform override: position "${v('position')}" rotation "${v('rotation')}" size ${holo.getAttribute('size')}`)
231    }
232  }
233
234  // Run immediately, not from boot(): holo4ds reads `size` AND `main-4ds` when the scene
235  // loads, which can be before the engine's async boot fires xrloaded.
236  applyExperience()
237
238  const boot = () => {
239    if (scene.hasLoaded) chooseMode()
240    else scene.addEventListener('loaded', chooseMode, {once: true})
241  }
242  if (window.XR8) boot()
243  else window.addEventListener('xrloaded', boot, {once: true})
244
245  // ---- the Continue gate ----------------------------------------------------
246
247  // The watchdog is deliberately NOT armed across the camera permission prompt. That
248  // prompt is modal to the user, not to the page, and someone who takes 20 seconds to read
249  // it would otherwise be shown "Could not start AR" while the dialog is still open. So:
250  // arm on runreality (catches the engine never starting at all), disarm while the camera
251  // is 'requesting', re-arm once the camera is live and realityready is the only thing
252  // left to wait for.
253  const armWatchdog = () => {
254    clearTimeout(watchdog)
255    watchdog = setTimeout(() => {
256      if (!realityReady) fallback('realityready timed out')
257    }, REALITY_TIMEOUT_MS)
258  }
259
260  const disarmWatchdog = () => {
261    clearTimeout(watchdog)
262    watchdog = null
263  }
264
265  // The camera only starts once BOTH the engine is attached (so the 'runreality' listener
266  // exists) and the user has tapped Continue. Either can happen first: xr-slam.js is 5.5 MB
267  // and on a slow connection the tap will beat it.
268  const maybeStart = () => {
269    if (running || !engineAttached || !continueTapped) return
270    running = true
271    scene.emit('runreality')
272    console.log('[ar] runreality emitted, mode =', mode)
273    armWatchdog()
274  }
275
276  // iOS 13+ gates DeviceMotion/DeviceOrientation behind a permission call that must
277  // originate in a user gesture. The engine asks for it itself, but only after its own
278  // async start-up, by which point Safari's transient activation may have lapsed. Asking
279  // here, inside the tap, means the engine's later request resolves instantly. This
280  // matters most in 3DoF, where the gyro is the entire tracking system.
281  const requestMotion = () => {
282    const DME = window.DeviceMotionEvent
283    if (!DME || typeof DME.requestPermission !== 'function') return Promise.resolve('granted')
284    return DME.requestPermission()
285      .then((r) => {
286        console.log('[ar] DeviceMotion permission:', r)
287        return r
288      })
289      .catch((err) => {
290        console.warn('[ar] DeviceMotion permission failed', err)
291        return 'denied'
292      })
293  }
294
295  // Mobile browsers block programmatic audio until a user gesture, and this tap is it.
296  // Two separate things need unlocking, for two different reasons.
297  const resumeAudio = () => {
298    // 1. The <audio> element carrying the narration. Playback is triggered later, when the
299    //    marker places the scan, by which point this gesture is long over — so prime the
300    //    element now with a play/pause inside the gesture. That is 
300what marks it
301    //    user-activated for the rest of the document's life on iOS. Must be called
302    //    synchronously here; the .then() only tidies up after.
303    const a = document.getElementById('audio-player')
304    if (a && a.src) {
305      const p = a.play()
306      if (p && p.then) {
307        p.then(() => {
308          a.pause()
309          a.currentTime = 0
310          console.log('[intro] narration unlocked')
311        }).catch((err) => console.warn('[intro] narration could not be unlocked', err))
312      }
313    }
314    // 2. The 4DViews WebAudio context. It carries no narration any more (see the NARRATION
315    //    block in experiences.js) and these scans have no embedded track, so this is inert
316    //    today. Kept because the context is still constructed, and because leaving it
317    //    suspended would be a trap for anyone who ever gives a scan its own audio.
318    const m = holoModel()
319    if (m && m.audioCtx && m.audioCtx.state === 'suspended') {
320      m.audioCtx.resume().then(() => console.log('[intro] audio context resumed'))
321    }
322  }
323
324  continueBtn.addEventListener('click', () => {
325    // Both of these must be invoked synchronously inside the gesture task.
326    resumeAudio()
327    const motion = requestMotion()
328    introOverlay.classList.add('hidden')
329    continueTapped = true
330    motion.then(maybeStart)
331  }, {once: true})
332
333  retryBtn.addEventListener('click', () => window.location.reload())
334
335  // ---- engine events --------------------------------------------------------
336
337  scene.addEventListener('realityready', () => {
338    realityReady = true
339    disarmWatchdog()
340    console.log('[ar] realityready, mode =', mode)
341    if (uiContainer) {
342      uiContainer.style.display = 'block'
343      setTimeout(() => uiContainer.classList.add('ready'), 100)
344    }
345    if (scanPrompt && mode === '3dof') {
346      defaultScanPrompt = 'Point your camera at the marker and hold your phone steady'
347      scanPrompt.innerHTML = defaultScanPrompt
348    }
349  })
350
351  scene.addEventListener('camerastatuschange', (e) => {
352    const s = e.detail && e.detail.status
353    console.log('[ar] camera status:', s)
354    if (s === 'requesting') {
355      // The permission prompt is open. However long the user takes is fine.
356      disarmWatchdog()
357      return
358    }
359    if (s === 'hasStream' || s === 'hasVideo') {
360      // Camera is live, so realityready is the only thing left to wait for.
361      if (!realityReady) armWatchdog()
362      return
363    }
364    // Not a tracking fallback: 3DoF needs the camera just as much as 6DoF does, so
365    // dropping to 3DoF would not help. This is a dead end until the user fixes it.
366    if (s === 'failed' || s === 'denied') {
367      disarmWatchdog()
368      showError(
369        'Camera access blocked',
370        'Camera permission was denied or the camera is unavailable. Allow camera access over a secure (https) connection, then tap Try again.'
371      )
372    }
373  })
374
375  scene.addEventListener('realityerror', (e) => {
376    const d = e.detail || {}
377    console.warn('[ar] realityerror', d)
378    if (d.isDeviceBrowserSupported === false) {
379      showError(
380        'Device not supported',
381        'This browser or device cannot run the AR experience. Try opening this page in Safari (iPhone) or Chrome (Android).'
382      )
383      return
384    }
385    fallback('realityerror')
386  })
387
388  // SLAM quality. NOT_AVAILABLE / UNSUPPORTED mean there is no world frame to anchor to,
389  // so world anchoring would be a lie and we are better off in honest 3DoF. LIMITED is
390  // transient (initialising, relocalising, too much motion, not enough features) and is
391  // coaching material, not a reason to tear the session down.
392  //
393  // Coaching only applies BEFORE the scan has been placed, where the prompt is on screen
394  // anyway and better tracking is something the user can actually act on. Once the scan is
395  // placed the prompt is gone and we leave it that way: re-pointing at the marker already
396  // repositions the anchor, and nagging over a playing narration would be worse than the
397  // drift it warns about.
398  const COACHING = {
399    INITIALIZING: 'Move your phone slowly to look around',
400    RELOCALIZING: 'Move your phone slowly to look around',
401    EXCESSIVE_MOTION: 'Slow down and hold your phone steadier',
402    INSUFFICIENT_FEATURES: 'Point at a more detailed part of the room',
403  }
404
405  scene.addEventListener('xrtrackingstatus', (e) => {
406    const d = e.detail
407    if (!d) return
408    console.log('[ar] tracking:', d.status, d.reason || '')
409
410    if (d.status === 'NOT_AVAILABLE' || d.status === 'UNSUPPORTED') {
411      fallback('trackingstatus ' + d.status)
412      return
413    }
414
415    if (!scanPrompt || anchorPlaced()) return
416    if (d.status === 'LIMITED' && COACHING[d.reason]) {
417      scanPrompt.textContent = COACHING[d.reason]
418    } else if (d.status === 'NORMAL') {
419      scanPrompt.innerHTML = defaultScanPrompt
420    }
421  })
422
423  // ---- wrong-marker redirect ------------------------------------------------
424  // A visitor on a Tom page pointing at Marina's card is sent straight to Marina's page, the
425  // take her #topbar link goes to (STORYTELLERS[].link). Same tab, no prompt.
426  //
427  // ANY TIME, including mid-story. It used to fire only before placement, which meant it
428  // worked exactly once in practice: arrive by redirect, tap Continue with the card still in
429  // view, the scan places instantly, and every other card is ignored from then on. The client
430  // wants to hop between storytellers by swapping cards, so it now always fires.
431  //
432  // Because it can now interrupt a playing story, a sighting has to HOLD: the other card must
433  // stay tracked for WRONG_MARKER_DWELL_MS without an xrimagelost before we leave. A one-frame
434  // misreport (the engine briefly naming the wrong target) is dropped instead of costing the
435  // visitor their story.
436  //
437  // location.replace, not href: the page they were bounced off is not somewhere they chose to
438  // be, so Back from the new page skips it and returns to wherever they came from.
439  //
440  // The new page shows its own Continue gate. That is unavoidable, not a bug: audio, camera
441  // and iOS motion permission all have to be unlocked by a tap on the document that uses
442  // them (see "The two-tap flow" in CLAUDE.md).
443  const WRONG_MARKER_DWELL_MS = 500
444  let redirecting = false
445  const pendingRedirect = {}  // target name -> dwell timer
446
447  const onOtherCardSeen = (e) => {
448    const d = e.detail
449    if (redirecting || !d || !d.name || d.name === IMAGE_TARGET.name || pendingRedirect[d.name]) return
450    const person = window.storytellerByTarget(d.name)
451    if (!person || !person.link) return
452    console.log(`[ar] other card seen: ${d.name}, redirecting if it holds for ${WRONG_MARKER_DWELL_MS}ms`)
453    pendingRedirect[d.name] = setTimeout(() => {
454      delete pendingRedirect[d.name]
455      if (redirecting) return
456      redirecting = true
457      console.log(`[ar] wrong marker: ${d.name} -> ${person.link}.html`)
458      window.location.replace(`${person.link}.html`)
459    }, WRONG_MARKER_DWELL_MS)
460  }
461
462  const onOtherCardLost = (e) => {
463    const d = e.detail
464    if (!d || !pendingRedirect[d.name]) return
465    clearTimeout(pendingRedirect[d.name])
466    delete pendingRedirect[d.name]
467    console.log(`[ar] other card lost before the dwell, not redirecting: ${d.name}`)
468  }
469
470  scene.addEventListener('xrimagefound', onOtherCardSeen)
471  scene.addEventListener('xrimageupdated', onOtherCardSeen)
472  scene.addEventListener('xrimagelost', onOtherCardLost)
473
474  // ---- Path C: drop to gyro-only 3DoF ---------------------------------------
475
476  const fallback = (why) => {
477    if (mode === '3dof') {
478      // Already as degraded as we go.
479      showError(
480        'Could not start AR',
481        'Something went wrong starting the camera. Reload the page and try again.'
482      )
483      return
484    }
485    console.warn('[ar] SLAM unavailable, dropping to 3DoF:', why)
486    disarmWatchdog()
487    try {
488      // XR8.stop() releases the engine's post-run configuration latch, so the reconfigure
489      // below is legal. If this in-place switch turns out not to work on device, the
490      // sessionStorage + reload path underneath it is the guaranteed route.
491      XR8.stop()
492      XR8.XrController.configure({
493        disableWorldTracking: true,
494        scale: 'responsive',
495        // All three, same as attachEngine(): leaving this as just IMAGE_TARGET would make
496        // the wrong-marker redirect silently disappear in 3DoF.
497        imageTargetData: ALL_TARGETS,
498      })
499      mode = '3dof'
500      realityReady = false
501      scene.emit('runreality')
502      // If the in-place switch does not take, fall back to the guaranteed route.
503      clearTimeout(watchdog)
504      watchdog = setTimeout(() => {
505        if (!realityReady) forceReload3dof()
506      }, REALITY_TIMEOUT_MS)
507    } catch (err) {
508      console.error('[ar] in-place 3DoF switch failed, reloading', err)
509      forceReload3dof()
510    }
511  }
512
513  const forceReload3dof = () => {
514    sessionStorage.setItem('ar-force-3dof', '1')
515    window.location.reload()
516  }
517
518  // ---- backgrounding --------------------------------------------------------
519
520  // Deliberately NOT routed through targetLost (which would flash the scan prompt back on
521  // return) and deliberately NOT xrextras-pause-on-hidden, which calls sceneEl.pause():
522  // that stops A-Frame's tick, so holo4ds stops calling model4DS.update(), while WEB4DS's
523  // own decode interval and the WebAudio buffer source keep running. The result is audio
524  // playing over a frozen scan. WEB4DS.pause()/play() is the correct lever.
525  //
526  // The narration is a separate <audio> element and needs its own pass. Mobile browsers
527  // will happily keep an <audio> playing with the tab hidden or the phone locked, which for
528  // a story means the visitor comes back to a narration that has run on without them.
529  // Tracked separately from the scan so that a narration the user had deliberately paused
530  // is not resumed for them on return.
531  document.addEventListener('visibilitychange', () => {
532    const audio = document.getElementById('audio-player')
533    const m = holoModel()
534    if (document.visibilityState === 'hidden') {
535      if (audio && !audio.paused) {
536        audio.pause()
537        narrationPlayingOnHide = true
538        console.log('[ar] backgrounded, narration paused')
539      }
540      if (m && m.isLoaded && m.isPlaying) {
541        m.pause()
542        scanPlayingOnHide = true
543        console.log('[ar] backgrounded, scan paused')
544      }
545      return
546    }
547    if (narrationPlayingOnHide && audio) {
548      audio.play().catch((err) => console.warn('[ar] narration could not resume', err))
549      narrationPlayingOnHide = false
550      console.log('[ar] foregrounded, narration resumed')
551    }
552    if (scanPlayingOnHide && m && m.isLoaded) {
553      if (m.audioCtx && m.audioCtx.state === 'suspended') m.audioCtx.resume()
554      m.play()
555      scanPlayingOnHide = false
556      console.log('[ar] foregrounded, scan resumed')
557    }
558  })
559})()

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.