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.