1import { setReplayMediaRate } from "./replay-speed.js"; 2// Used only behind the mobile-device gate. Preference controls output, never 3// transport lifetime. Every retry reads today's segment/clock, not a stale 4// play() promise or the position where a suspended browser stopped. 5export function createMobileBroadcastTransport(media, read, { 6 now = () => performance.now(), 7 schedule = (fn) => window.setInterval(fn, 250), 8 cancel = (id) => window.clearInterval(id), 9} = {}) { 10 let disposed = false, key = null, generation = 0, pending = false; 11 let lastTime = NaN, lastProgress = now(), lastReload = -Infinity; 12 let blocked = false, completed = false, needsAlignment = true; 13 let initialAlignment = true; 14 let lastMuted = null; 15 let frameRequest = 0, lastFrameAt = now(); 16 const observesFrames = typeof media.requestVideoFrameCallback === "function"; 17 const observeFrame = () => { 18 if (!observesFrames || disposed) return; 19 frameRequest = media.requestVideoFrameCallback(() => { 20 lastFrameAt = now(); 21 observeFrame(); 22 }); 23 }; 24 observeFrame(); 25 const valid = (version) => !disposed && generation === version && 26 read()?.key === key && read()?.active; 27 const align = (segment) => { 28 if (media.readyState < 1) return; 29 // Narration may protect its first sample from ordinary boundary/loading 30 // latency. This applies once per segment, never to stall/foreground recovery. 31 const requestedTime = initialAlignment && Number.isFinite(segment.initialTime) 32 ? segment.initialTime : segment.time; 33 let target = Math.max(0, Number(requestedTime) || 0); 34 if (Number.isFinite(media.duration) && media.duration > 0) { 35 if (segment.loop) target %= media.duration; 36 else if (target >= media.duration - 0.01) { 37 completed = true; 38 media.pause(); 39 return; 40 } 41 } 42 if (Math.abs(media.currentTime - target) > 0.08) { 43 try { media.currentTime = target; } catch { return; } 44 } 45 needsAlignment = false; 46 initialAlignment = false; 47 lastTime = media.currentTime; 48 lastProgress = now(); 49 }; 50 const play = () => { 51 if (pending || completed || disposed) return; 52 const version = generation; 53 pending = true; 54 let result; 55 try { result = media.play(); } catch (error) { result = Promise.reject(error); } 56 Promise.resolve(result).then(() => { 57 if (!valid(version)) return; 58 pending = false; 59 }, (error) => { 60 if (!valid(version)) return; 61 pending = false; 62 if (error?.name === "NotAllowedError" && !media.muted) { 63 // Keep the viewer's sound preference; retry only this element silently. 64 blocked = true; 65 media.muted = true; 66 media.dataset.mobileBroadcastSound = "gesture-required"; 67 play(); 68 } 69 }); 70 }; 71 function tick({ fromGesture = false } = {}) { 72 if (disposed) return; 73 const segment = read(); 74 if (!segment?.active) { 75 if (!media.paused) media.pause(); 76 if (pending) { generation += 1; pending = false; } 77 needsAlignment = true; 78 if (key !== null) initialAlignment = false; 79 lastProgress = now(); 80 lastFrameAt = now(); 81 return; 82 } 83 if (key !== segment.key) { 84 key = segment.key; 85 generation += 1; 86 pending = false; 87 blocked = false; 88 completed = false; 89 needsAlignment = true; 90 initialAlignment = true; 91 lastTime = NaN; 92 lastProgress = now(); 93 lastFrameAt = now(); 94 lastReload = -Infinity; 95 if (segment.src && media.src !== new URL(segment.src, window.location.href).href) { 96 media.src = segment.src; 97 media.load(); 98 } 99 } 100 if (fromGesture || (lastMuted === true && !segment.muted)) blocked = false; 101 lastMuted = Boolean(segment.muted); 102 media.muted = lastMuted || blocked; 103 media.dataset.mobileBroadcastSound = blocked && !lastMuted 104 ? "gesture-required" : lastMuted ? "muted" : "on"; 105 setReplayMediaRate(media, segment.rate || 1); 106 media.loop = Boolean(segment.loop); 107 if (!needsAlignment && media.ended && !segment.loop) completed = true; 108 if (completed) return; 109 const progressed = !media.paused && Number.isFinite(lastTime) && 110 Math.abs(media.currentTime - lastTime) > 0.015; 111 if (progressed) lastProgress = now(); 112 lastTime = media.currentTime; 113 if (needsAlignment) align(segment); 114 if (completed) return; 115 // An initial metadata request is not a frozen playing clock. Quiz may give 116 // that first request a little longer without changing recovery after start. 117 const progressTimeout = initialAlignment && media.readyState < 1 118 ? Math.max(1250, Math.min(3000, Number(segment.initialLoadGraceMs) || 0)) 119 : 1250; 120 const stalled = now() - lastProgress > progressTimeout || 121 (observesFrames && now() - lastFrameAt > 1500); 122 if (stalled && now() - lastReload > 4000) { 123 // Covers unresolved play(), buffering and frozen clocks. A bounded reload 124 // invalidates old promises and resumes at the current broadcast position. 125 generation += 1; 126 pending = false;
127 lastReload = now(); 128 lastProgress = now(); 129 lastFrameAt = now(); 130 needsAlignment = true; 131 initialAlignment = false; 132 media.load(); 133 align(segment); 134 } 135 if (needsAlignment) return; 136 if (media.paused) play(); 137 } 138 const onReady = () => tick(); 139 const onGesture = () => tick({ fromGesture: true }); 140 const onMute = () => tick({ fromGesture: read()?.muted === false }); 141 media.addEventListener("loadedmetadata", onReady); 142 media.addEventListener("canplay", onReady); 143 window.addEventListener("channel155:mobile-playback-gesture", onGesture); 144 window.addEventListener("channel:mute-changed", onMute); 145 window.addEventListener("channel155:mobile-playback-state-ready", onReady); 146 const timer = schedule(tick); 147 tick(); 148 return { 149 tick, 150 dispose() { 151 disposed = true; 152 generation += 1; 153 cancel(timer); 154 if (observesFrames) media.cancelVideoFrameCallback?.(frameRequest); 155 media.removeEventListener("loadedmetadata", onReady); 156 media.removeEventListener("canplay", onReady); 157 window.removeEventListener("channel155:mobile-playback-gesture", onGesture); 158 window.removeEventListener("channel:mute-changed", onMute); 159 window.removeEventListener("channel155:mobile-playback-state-ready", onReady); 160 media.pause(); 161 }, 162 }; 163}
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.