1import { Controller } from "@hotwired/stimulus" 2import { Howl } from "howler" 3 4// Narrated walkthrough player â direct port of book.jsx's VariantConsole 5// stepper (Image #12). On step change we mark the matching code lines as 6// `data-active="true"` and expand the matching step row. 7// 8// Two playback modes, picked at connect(): 9// ⢠audio â each step has an `audio_url` (mp3 hosted on S3, rendered by 10// Courses::Ai::GenerateEmbedAudio). We instantiate one Howl per step 11// and use the active Howl's `seek()` to drive the elapsed counter; 12// `onend` advances to the next step. 13// ⢠synthetic â no audio yet. Falls back to a requestAnimationFrame 14// loop ticking through `step-times` (seconds from `steps[].at`), so 15// the lesson still flows even before narration has been generated. 16export default class extends Controller { 17 static targets = ["indicator", "play", "elapsed"] 18 static values = { 19 step: { type: Number, default: 0 }, 20 stepTimes: { type: Array, default: [] }, 21 stepAudios: { type: Array, default: [] }, 22 duration: { type: Number, default: 0 } 23 } 24 25 connect() { 26 this.steps = this.element.querySelectorAll(".lb-wt-step") 27 this.lines = this.element.querySelectorAll(".lb-wt-line") 28 this.playing = false 29 this.tickStart = 0 // performance.now() at play (synthetic-mode only) 30 this.elapsedAtStart = 0 31 32 // Mode detection: any non-blank URL flips us into audio mode. 33 const urls = this.stepAudiosValue || [] 34 this.audioMode = urls.some(u => typeof u === "string" && u.length > 0) 35 if (this.audioMode) this.howls = urls.map(u => u ? new Howl({ src: [u], html5: true, preload: "metadata" }) : null) 36 37 this.stepValueChanged() 38 } 39 40 disconnect() { 41 if (this.rafId) cancelAnimationFrame(this.rafId) 42 if (this.howls) this.howls.forEach(h => h && h.unload()) 43 } 44 45 // Stop whichever Howl is currently playing, if any. Used on step change 46 // and on pause so we don't have two clips fighting for the speaker. 47 stopActiveHowl() { 48 if (!this.audioMode) return 49 this.howls?.forEach(h => h && h.stop()) 50 } 51 52 // ââ step state ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 53 54 selectStep(event) { 55 const idx = parseInt(event.currentTarget.dataset.stepIndex || "0", 10) 56 const wasPlaying = this.playing 57 if (this.audioMode) this.stopActiveHowl() 58 this.stepValue = idx 59 // Manual click â snap the clock so play resumes from this step. 60 this.elapsedAtStart = (this.stepTimesValue[idx] || 0) 61 this.tickStart = performance.now() 62 this.renderElapsed(this.elapsedAtStart) 63 if (wasPlaying) { 64 this.playing = false 65 this.play() 66 } 67 } 68 69 prevStep() { 70 const wasPlaying = this.playing 71 if (this.audioMode) this.stopActiveHowl() 72 this.stepValue = Math.max(0, this.stepValue - 1) 73 this.elapsedAtStart = (this.stepTimesValue[this.stepValue] || 0) 74 this.tickStart = performance.now() 75 if (wasPlaying && this.audioMode) this.playCurrentHowl() 76 } 77 78 nextStep() { 79 const wasPlaying = this.playing 80 if (this.audioMode) this.stopActiveHowl() 81 this.stepValue = Math.min(this.steps.length - 1, this.stepValue + 1) 82 this.elapsedAtStart = (this.stepTimesValue[this.stepValue] || 0) 83 this.tickStart = performance.now() 84 if (wasPlaying && this.audioMode) this.playCurrentHowl() 85 } 86 87 stepValueChanged() { 88 const i = this.stepValue 89 this.steps?.forEach((el, k) => { el.dataset.active = (k === i) ? "true" : "false" }) 90 this.lines?.forEach((el) => { 91 const stepIdxs = (el.dataset.steps || "").split(",").filter(Boolean).map(Number) 92 el.dataset.active = stepIdxs.includes(i) ? "true" : "false" 93 }) 94 if (this.hasIndicatorTarget && this.steps?.length) { 95 this.indicatorTarget.textContent = `${i + 1} of ${this.steps.length}` 96 } 97 } 98 99 // ââ play / pause ââââââââââââââââââââââââââââââââââââââââââââââââââââ 100 101 togglePlay() { 102 if (this.playing) this.pause(); else this.play() 103 } 104 105 play() { 106 if (this.playing) return 107 // Reaching the end â rewind to the start before resuming. 108 if (this.stepValue >= this.steps.length - 1 && 109 this.elapsedAtStart >= this.durationValue - 0.1) { 110 this.stepValue = 0 111 this.elapsedAtStart = 0 112 } 113 this.playing = true 114 this.tickStart = performance.now() 115 this.setPauseIcon() 116 if (this.audioMode) this.playCurrentHowl() 117 this.tick() 118 } 119 120 pause() { 121 this.playing = false 122 // Freeze the clock where it is so a resume picks up smoothly. 123 this.elapsedAtStart = this.currentElapsed() 124 if (this.rafId) cancelAnimationFrame(this.rafId) 125 if (this.audioMode) this.stopActiveHowl() 126 this.setPlayIcon() 127 } 128 129 setPlayIcon() { 130 if (!this.hasPlayTarget) return 131 this.playTarget.textContent = "â¶" 132 this.playTarget.setAttribute("aria-label", "Play walkthrough") 133 } 134 135 setPauseIcon() { 136 if (!this.hasPlayTarget) return 137 this.playTarget.textContent = "â¸" 138 this.playTarget.setAttribute("aria-label", "Pause walkthrough") 139 } 140 141 // Start the Howl for the current step. On end, advance to the next 142 // step (which retriggers this via stepValueChanged); when the final 143 // step's audio ends, pause. 144 playCurrentHowl() { 145 const howl = this.howls?.[this.stepValue] 146 if (!howl) return 147 howl.off("end") 148 howl.once("end", () => { 149 if (!this.playing) return 150 const next = this.stepValue + 1 151 if (next < this.steps.length) { 152 this.stepValue = next 153 this.playCurrentHowl() 154 } else { 155 this.pause() 156 } 157 }) 158 howl.play() 159 } 160 161 tick = () => { 162 if (!this.playing) return 163 const t = this.currentElapsed() 164 this.renderElapsed(t) 165 166 // In synthetic mode the rAF clock is also the step-advance driver. 167 // In audio mode the Howl's `onend` is authoritative â don't let the 168 // visual clock race ahead and double-advance. 169 if (!this.audioMode) { 170 const times = this.stepTimesValue 171 for (let i = times.length - 1; i >= 0; i--) { 172 if (t + 0.01 >= times[i]) { 173 if (this.stepValue !== i) this.stepValue = i 174 break 175 } 176 } 177 if (t >= this.durationValue) { this.pause(); return } 178 } 179 180 this.rafId = requestAnimationFrame(this.tick) 181 } 182 183 currentElapsed() { 184 if (this.audioMode && this.playing) { 185 const howl = this.howls?.[this.stepValue] 186 const seek = howl ? Math.max(0, Number(howl.seek()) || 0) : 0 187 return (this.stepTimesValue[this.stepValue] || 0) + seek 188 } 189 if (!this.playing) return this.elapsedAtStart 190 return this.elapsedAtStart + (performance.now() - this.tickStart) / 1000 191 } 192 193 renderElapsed(t) { 194 if (!this.hasElapsedTarget) return 195 this.elapsedTarget.textContent = `${fmt(t)} · ${fmt(this.durationValue)}` 196 } 197} 198 199function fmt(s) { 200 const total = Math.max(0, Math.round(s)) 201 const m = Math.floor(total / 60) 202 const r = total % 60 203 return `${m}:${r.toString().padStart(2, "0")}` 204}
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.