1import { createMobileBroadcastTransport } from "./mobile-broadcast-transport.js?v=20260909-mobile-quiz-boundaries-v3-20260912-replay-hold-speed-v1"; 2 3// Caption alignment is approximate; leave a little more of the recorded attack 4// on mobile instead of treating the first caption timestamp as a sample cut. 5export function mobileQuizVoiceTrimSeconds(cues) { 6 const starts = (Array.isArray(cues) ? cues : []) 7 .map(cue => Number(cue?.startMs)) 8 .filter(value => Number.isFinite(value) && value >= 0); 9 return starts.length ? Math.max(0, Math.min(...starts) - 180) / 1000 : 0; 10} 11 12// One player for the lifetime of the mobile quiz, not one per React effect. 13// Question timers and show state are never delayed by this short audio handoff. 14export function createMobileQuizNarration(media, { 15 isActive, 16 isMuted, 17 now = () => performance.now(), 18 schedule = fn => window.setInterval(fn, 50), 19 cancel = id => window.clearInterval(id), 20 createTransport = createMobileBroadcastTransport, 21} = {}) { 22 let requested = null, current = null, drainUntil = null; 23 let started = false, captionTime = null, disposed = false; 24 const sameSource = () => current && media.currentSrc === 25 new URL(current.src, window.location.href).href; 26 const remainingMs = () => (media.duration - media.currentTime) / 27 (media.playbackRate || current?.rate || 1) * 1000; 28 const read = () => { 29 const active = !disposed && isActive(); 30 if (current?.key !== requested?.key) { 31 const remaining = remainingMs(); 32 const canFinish = active && current && 33 (!requested || requested.runId === current.runId) && sameSource() && 34 started && !media.paused && !media.ended && media.readyState >= 2 && 35 Number.isFinite(remaining) && remaining > 0 && remaining <= 900; 36 if (canFinish && drainUntil === null) { 37 drainUntil = now() + Math.min(900, remaining + 100); 38 } 39 if (!canFinish || drainUntil !== null && now() >= drainUntil) { 40 current = requested; 41 drainUntil = null; 42 started = false; 43 captionTime = null; 44 } 45 } else if (current && requested) { 46 // A same-segment render may refresh clock data; it must not restart audio. 47 current = requested; 48 drainUntil = null; 49 } 50 return current ? { 51 key: current.key, src: current.src, 52 active: active && !(current.startsInSeconds?.() > 0), 53 time: current.readTime(), initialTime: current.initialTime, 54 initialLoadGraceMs: 3000, 55 rate: current.rate, muted: isMuted(), 56 } : { active: false }; 57 }; 58 const transport = createTransport(media, read, { now, schedule, cancel }); 59 const onPlaying = () => { if (sameSource()) started = true; }; 60 const onEnded = () => transport.tick(); 61 media.addEventListener("playing", onPlaying); 62 media.addEventListener("ended", onEnded); 63 return { 64 update(segment) { 65 if (disposed) return; 66 const sameKey = segment && requested?.key === segment.key; 67 requested = segment ? { 68 ...segment, 69 // Freeze the boundary decision before metadata loading / tail draining. 70 initialTime: sameKey ? requested.initialTime : segment.initialTime, 71 } : null; 72 transport.tick(); 73 }, 74 timeFor(segmentKey) { 75 if (!isActive() || !segmentKey || requested?.key !== segmentKey || 76 current?.key !== segmentKey || !started) return null; 77 if (sameSource() && media.readyState >= 1 && Number.isFinite(media.currentTime)) { 78 captionTime = Math.max(captionTime ?? 0, media.currentTime * 1000); 79 } 80 // Hold through buffering/reload; never flash ahead on the broadcast clock 81 // or borrow the previous clip's currentTime during React's render/commit. 82 return captionTime; 83 }, 84 dispose() { 85 disposed = true; 86 media.removeEventListener("playing", onPlaying); 87 media.removeEventListener("ended", onEnded);
88 transport.dispose(); 89 }, 90 }; 91}
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.