PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/courses/embed_player_controller-1a70f007.js

js littlebirdelectronics.com.au collected 2026-09-25 14:09:10 UTC 7,169 bytes, 204 lines download raw bytes

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.