PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/instructional_video_controller-a9c8cb1f.js

js littlebirdelectronics.com.au collected 2026-10-03 19:19:12 UTC 15,053 bytes, 175 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { activeAnnotations, framePoint, rectangle, timeLabel, annotationError, cameraAt, clamp } from "src/video_annotations"
3import { VideoPresentation } from "src/video_presentation"
4
5export default class extends Controller {
6  connect() {
7    this.doc = JSON.parse(this.element.querySelector("[data-video-document]").textContent)
8    this.editor = this.element.dataset.editor === "true"
9    this.video = this.find("video"); this.frame = this.find("frame")
10    this.overlay = this.find("overlay"); this.labels = this.find("labels")
11    this.find("player").style.setProperty("--iv-ratio", this.doc.width / this.doc.height)
12    this.annotations = structuredClone(this.doc.annotations)
13    this.showMarkup = true; this.autoFocus = true; this.history = []; this.future = []; this.selected = null
14    this.saved = this.snapshot(); this.abort = new AbortController()
15    this.resizeObserver = new ResizeObserver(() => this.render(true)); this.resizeObserver.observe(this.frame)
16    this.presentation = new VideoPresentation({video: this.video, frame: this.frame, overlay: this.overlay, labels: this.labels, width: this.doc.width, height: this.doc.height, editor: this.editor})
17    const on = (event, callback) => this.video.addEventListener(event, callback, { signal: this.abort.signal })
18    for (const event of ["timeupdate", "seeked", "loadedmetadata", "pause", "ended", "volumechange"]) on(event, () => this.render())
19    on("play", () => { this.render(); this.tick() })
20    on("error", () => { this.find("player-status").textContent = "The video could not be loaded. Please try again." })
21    this.find("mute").hidden = this.doc.has_audio === false
22    this.find("markup").hidden = !this.editor && !this.annotations.some(a => a.type !== "zoom")
23    this.find("focus").hidden = !this.editor && !this.annotations.some(a => a.type === "zoom")
24    this.find("controls").hidden = false; this.video.controls = false
25    if (this.editor) {
26      this.beforeUnload = event => { if (this.snapshot() !== this.saved) { event.preventDefault(); event.returnValue = "" } }
27      window.addEventListener("beforeunload", this.beforeUnload, { signal: this.abort.signal })
28      document.addEventListener("turbo:before-visit", event => {
29        if (this.snapshot() !== this.saved && !window.confirm("Leave without saving your video annotations?")) event.preventDefault()
30      }, { signal: this.abort.signal })
31      this.renderEditor()
32    }
33    this.render(true)
34  }
35  disconnect() { this.presentation?.destroy(); this.abort?.abort(); this.resizeObserver?.disconnect(); cancelAnimationFrame(this.raf); this.video?.pause() }
36  find(name) { return this.element.querySelector(`[data-${name}]`) }
37  snapshot() { return JSON.stringify({ annotations: this.annotations, title: this.find("title")?.value ?? this.doc.title, published: this.find("published")?.checked ?? this.doc.published }) }
38  remember() { this.history.push(this.snapshot()); if (this.history.length > 100) this.history.shift(); this.future = [] }
39  changed() { this.renderEditor(); this.render(true) }
40  metadataChanged() { this.find("save-state").textContent = this.snapshot() === this.saved ? "All changes saved" : "Unsaved changes" }
41  tick() { cancelAnimationFrame(this.raf); if (!this.video.paused && !this.video.ended) this.raf = requestAnimationFrame(() => { this.render(); this.tick() }) }
42  togglePlay() { this.video.paused ? this.video.play().catch(() => { this.find("player-status").textContent = "Press Play to start the video." }) : this.video.pause() }
43  seek(event) { this.video.currentTime = Number(event.target.value); this.render(true) }
44  speed(event) { this.video.playbackRate = Number(event.target.value) }
45  toggleMute() { this.video.muted = !this.video.muted }
46  toggleMarkup(event) { this.showMarkup = !this.showMarkup; event.currentTarget.setAttribute("aria-pressed", this.showMarkup); this.render(true) }
47  toggleFocus(event) { this.autoFocus = !this.autoFocus; event.currentTarget.setAttribute("aria-pressed", this.autoFocus); event.currentTarget.textContent = "Auto zoom"; this.render(true) }
48  async fullscreen() {
49    try { document.fullscreenElement ? await document.exitFullscreen() : await this.find("player").requestFullscreen() }
50    catch { this.find("player-status").textContent = "Fullscreen is not available in this browser." }
51  }
52  render(force = false) {
53    const time = this.video.currentTime
54    this.find("seek").value = time
55    this.find("time").textContent = `${timeLabel(time)} / ${timeLabel(this.doc.duration)}`
56    this.find("play").textContent = this.video.paused ? "Play" : "Pause"
57    this.find("play").setAttribute("aria-label", this.video.paused ? "Play video" : "Pause video")
58    this.find("mute").textContent = this.video.muted ? "Sound off" : "Sound on"
59    this.find("mute").setAttribute("aria-label", this.video.muted ? "Unmute video" : "Mute video")
60    const active = this.showMarkup ? activeAnnotations(this.annotations, time).filter(a => a.type !== "zoom") : []
61    if (this.drawing?.annotation) active.push({...this.drawing.annotation, type: this.drawing.annotation.type === "zoom" ? "box" : this.drawing.annotation.type})
62    const camera = cameraAt(this.autoFocus && !this.tool ? this.annotations : [], time, window.matchMedia("(prefers-reduced-motion: reduce)").matches)
63    const signature = JSON.stringify([active, this.selected, camera])
64    if (force || signature !== this.signature) { this.signature = signature; this.presentation.render(active, camera, this.selected) }
65    if (this.editor) this.find("timeline")?.style.setProperty("--playhead", `${clamp(time / this.doc.duration) * 100}%`)
66  }
67  chooseTool(event) { this.tool = event.currentTarget.dataset.tool; this.replacing = false; this.video.pause(); this.updateTools(); this.render(true) }
68  cancelTool() { this.tool = null; this.replacing = false; this.drawing = null; this.updateTools(); this.render(true) }
69  updateTools() {
70    this.frame.classList.toggle("iv-drawing", !!this.tool)
71    this.element.querySelectorAll("[data-tool]").forEach(b => b.setAttribute("aria-pressed", b.dataset.tool === this.tool))
72  }
73  drawStart(event) {
74    if (!this.editor || !this.tool || event.button !== 0) return
75    event.preventDefault(); this.video.pause(); this.frame.setPointerCapture(event.pointerId)
76    const point = framePoint(event.clientX, event.clientY, this.frame.getBoundingClientRect())
77    const start = Math.min(this.video.currentTime, Math.max(0, this.doc.duration - 0.1))
78    const previous = this.replacing && this.annotations.find(a => a.id === this.selected)
79    const annotation = previous ? structuredClone(previous) : { id: crypto.randomUUID(), type: this.tool, start, end: Math.min(this.doc.duration, start + 3), color: "#facc15", text: this.tool === "label" ? "Your instruction" : "" }
80    Object.assign(annotation, point)
81    if (this.tool === "arrow") Object.assign(annotation, { x2: point.x, y2: point.y })
82    if (["box", "spotlight", "zoom"].includes(this.tool)) Object.assign(annotation, { width: 0, height: 0 })
83    this.drawing = { point, annotation, pointerId: event.pointerId }
84    this.render(true)
85  }
86  drawMove(event) {
87    if (!this.drawing || event.pointerId !== this.drawing.pointerId) return
88    const point = framePoint(event.clientX, event.clientY, this.frame.getBoundingClientRect()), a = this.drawing.annotation
89    if (a.type === "arrow") Object.assign(a, { x2: point.x, y2: point.y })
90    else if (a.type !== "label") Object.assign(a, rectangle(this.drawing.point, point))
91    this.render(true)
92  }
93  drawEnd(event) {
94    if (!this.drawing || event.pointerId !== this.drawing.pointerId) return
95    this.drawMove(event)
96    const a = this.drawing.annotation, error = annotationError(a, this.doc.duration, this.annotations.filter(item => item.id !== a.id))
97    this.frame.releasePointerCapture(event.pointerId); this.drawing = null
98    if (error) { this.find("error").textContent = error; this.render(true); return }
99    if (!this.replacing && this.annotations.length >= 200) { this.find("error").textContent = "Use at most 200 annotations."; return }
100    this.remember()
101    if (this.replacing) this.annotations = this.annotations.map(item => item.id === a.id ? a : item)
102    else this.annotations.push(a)
103    this.selected = a.id; this.cancelTool(); this.changed()
104    if (a.type === "label") this.element.querySelector('[data-field="text"]').focus()
105  }
106  drawCancel() { this.drawing = null; this.render(true) }
107  select(id) { this.element.querySelectorAll("[aria-invalid]").forEach(field => field.removeAttribute("aria-invalid")); this.selected = id; this.cancelTool(); const a = this.annotations.find(a => a.id === id); if (a) { this.video.pause(); this.video.currentTime = a.type === "zoom" ? (a.start + a.end) / 2 : a.start }; this.changed() }
108  renderEditor() {
109    const list = this.find("annotation-list"), timeline = this.find("timeline")
110    list.replaceChildren(); timeline.replaceChildren()
111    if (!this.annotations.length) { const p = document.createElement("p"); p.className = "iv-hint"; p.textContent = "Add an arrow, highlight or label at the current video time."; list.append(p) }
112    for (const a of [...this.annotations].sort((a, b) => a.start - b.start)) {
113      const button = document.createElement("button"); button.type = "button"; button.className = `iv-list-item ${a.id === this.selected ? "is-selected" : ""}`
114      button.textContent = `${timeLabel(a.start)}–${timeLabel(a.end)}  ·  ${a.type}  ${a.text || ""}`
115      button.addEventListener("click", () => this.select(a.id)); list.append(button)
116      const track = document.createElement("div"); track.className = "iv-track"
117      const clip = document.createElement("button"); clip.type = "button"; clip.className = "iv-clip"; clip.title = button.textContent
118      clip.textContent = a.text || a.type; clip.style.left = `${a.start / this.doc.duration * 100}%`;
118 clip.style.width = `${(a.end - a.start) / this.doc.duration * 100}%`; clip.style.background = a.color || "#facc15"
119      clip.addEventListener("click", () => this.select(a.id)); track.append(clip); timeline.append(track)
120    }
121    const a = this.annotations.find(a => a.id === this.selected)
122    this.find("properties").hidden = !a; this.find("selection-empty").hidden = !!a
123    if (!a) this.element.querySelectorAll("[aria-invalid]").forEach(field => field.removeAttribute("aria-invalid"))
124    this.element.querySelectorAll("[data-callout-only]").forEach(field => field.hidden = a?.type === "zoom")
125    if (a) for (const field of this.element.querySelectorAll("[data-field]")) {
126      const key = field.dataset.field, coordinate = ["x", "y", "width", "height", "x2", "y2", "label_x", "label_y"].includes(key)
127      if (document.activeElement !== field) field.value = coordinate ? Math.round((a[key] ?? a[key === "label_x" ? "x" : "y"] ?? 0) * 1000) / 10 : a[key] ?? ""
128      const row = field.closest("[data-coordinate]")
129      if (row) row.hidden = (a.type === "zoom" && key.startsWith("label_")) || (!(key in a) && !key.startsWith("label_"))
130    }
131    this.find("undo").disabled = !this.history.length; this.find("redo").disabled = !this.future.length
132    this.find("error").textContent = ""; this.metadataChanged()
133  }
134  changeProperty(event) {
135    const a = this.annotations.find(a => a.id === this.selected); if (!a) return
136    const key = event.target.dataset.field, updated = { ...a }
137    updated[key] = ["text", "color", "title"].includes(key) ? event.target.value : Number(event.target.value) / (["start", "end", "number"].includes(key) ? 1 : 100)
138    if (["number", "label_x", "label_y"].includes(key) && event.target.value === "") delete updated[key]
139    const error = annotationError(updated, this.doc.duration, this.annotations.filter(item => item.id !== a.id))
140    if (error) { this.find("error").textContent = error; event.target.setAttribute("aria-invalid", "true"); return }
141    event.target.removeAttribute("aria-invalid"); if (JSON.stringify(a) === JSON.stringify(updated)) return
142    this.remember(); this.annotations = this.annotations.map(item => item.id === a.id ? updated : item); this.changed()
143  }
144  usePlayhead(event) {
145    const field = this.element.querySelector(`[data-field="${event.currentTarget.dataset.bound}"]`)
146    field.value = Math.min(this.video.currentTime, this.doc.duration).toFixed(2); this.changeProperty({ target: field })
147  }
148  reposition() { const a = this.annotations.find(a => a.id === this.selected); if (a) { this.tool = a.type; this.replacing = true; this.video.pause(); this.updateTools(); this.render(true) } }
149  duplicate() {
150    const a = this.annotations.find(a => a.id === this.selected); if (!a || this.annotations.length >= 200) return
151    if (a.type === "zoom") { this.find("error").textContent = "Add a zoom at a different time; zoom regions cannot overlap."; return }
152    this.remember(); const copy = { ...a, id: crypto.randomUUID() }; this.annotations.push(copy); this.selected = copy.id; this.changed()
153  }
154  remove() { this.remember(); this.annotations = this.annotations.filter(a => a.id !== this.selected); this.selected = null; this.changed() }
155  restore(snapshot) { const state = JSON.parse(snapshot); this.annotations = state.annotations; this.find("title").value = state.title; this.find("published").checked = state.published; this.selected = null; this.changed() }
156  undo() { if (this.history.length) { this.future.push(this.snapshot()); this.restore(this.history.pop()) } }
157  redo() { if (this.future.length) { this.history.push(this.snapshot()); this.restore(this.future.pop()) } }
158  async save() {
159    if (this.saving) return
160    if (this.element.querySelector('[aria-invalid="true"]')) { this.find("error").textContent = "Correct the highlighted field before saving."; return }
161    const snapshot = this.snapshot(), payload = { ...JSON.parse(snapshot), lock_ver
161sion: this.doc.lock_version }
162    this.saving = true; this.find("save-state").textContent = "Saving…"
163    try {
164      const response = await fetch(this.element.dataset.saveUrl, { method: "PATCH", headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || "" }, body: JSON.stringify(payload) })
165      const result = await response.json()
166      if (!response.ok) throw new Error(result.error || "Could not save this video.")
167      this.doc.lock_version = result.lock_version; this.saved = snapshot; this.metadataChanged(); this.find("error").textContent = ""
168    } catch (error) { this.find("save-state").textContent = "Not saved"; this.find("error").textContent = error.message }
169    finally { this.saving = false }
170  }
171  exportAnnotations() {
172    const blob = new Blob([JSON.stringify({ public_id: this.doc.public_id, annotations: this.annotations }, null, 2)], { type: "application/json" })
173    const url = URL.createObjectURL(blob), a = document.createElement("a"); a.href = url; a.download = `${this.doc.public_id}-annotations.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
174  }
175}

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.