PageSourceSearch

https://app.ricordi.ai/assets/services/recording_manager-0eeebaaa.js

js ricordi.ai collected 2026-10-05 15:49:20 UTC 10,501 bytes, 326 lines download raw bytes

1import { flash } from "helpers/flash"
2import { formatTime } from "helpers/format_time"
3import { populateSelectWithDevices } from "helpers/populate_select"
4import ChunkUploader from "services/chunk_uploader"
5
6const PREFERRED_VIDEO_MIME_TYPES = [
7  "video/webm;codecs=vp8,opus",
8  "video/webm",
9  "video/mp4",
10]
11
12const PREFERRED_AUDIO_MIME_TYPES = [
13  "audio/webm;codecs=opus",
14  "audio/webm",
15  "audio/mp4",
16]
17
18const CHUNK_INTERVAL_MS = 10_000
19const MAX_DURATION_SECONDS = 5 * 60 * 60
20
21class RecordingManager {
22  #state = "idle"
23  #mode = "audio"
24  #bucketId = null
25  #returnPath = null
26  #capturesPath = null
27  #chunkUrl = null
28  #completeUrl = null
29  #elapsed = 0
30  #timerInterval = null
31  #stream = null
32  #mediaRecorder = null
33  #uploader = null
34  #audioContext = null
35  #analyser = null
36  #beforeUnloadActive = false
37
38  get state() { return this.#state }
39  get mode() { return this.#mode }
40  get bucketId() { return this.#bucketId }
41  get returnPath() { return this.#returnPath }
42  get elapsed() { return this.#elapsed }
43  get stream() { return this.#stream }
44  get analyser() { return this.#analyser }
45
46  constructor() {
47    document.addEventListener("recording-manager:configure", (e) => this.#configure(e.detail))
48    document.addEventListener("recording-manager:set-mode", (e) => this.setMode(e.detail.mode))
49    document.addEventListener("recording-manager:start", () => this.start())
50    document.addEventListener("recording-manager:stop", () => this.stop())
51    document.addEventListener("recording-manager:discard", () => this.discard())
52    document.addEventListener("recording-manager:release-preview", () => this.releasePreview())
53    document.addEventListener("recording-manager:change-mic", (e) => this.#changeMic(e.detail.deviceId))
54    document.addEventListener("recording-manager:change-camera", (e) => this.#changeCamera(e.detail.deviceId))
55    document.addEventListener("recording-manager:attach-viewfinder", (e) => this.#attachViewfinder(e.detail.element))
56    document.addEventListener("recording-manager:detach-viewfinder", () => this.#detachViewfinder())
57    document.addEventListener("recording-manager:attach-waveform", (e) => this.#attachWaveform(e.detail.controller))
58    document.addEventListener("recording-manager:detach-waveform", (e) => this.#detachWaveform(e.detail.controller))
59    document.addEventListener("recording-manager:sync", () => this.#dispatch("state-changed"))
60    document.addEventListener("turbo:render", () => this.#renderIndicators())
61  }
62
63  #configure({ bucketId, chunkUrl, completeUrl, returnPath, capturesPath }) {
64    if (this.#state !== "idle") return
65    this.#bucketId = bucketId
66    this.#returnPath = returnPath
67    this.#capturesPath = capturesPath
68    this.#chunkUrl = chunkUrl
69    this.#completeUrl = completeUrl
70  }
71
72  async setMode(mode) {
73    if (this.#state === "recording" || this.#state === "saving" || this.#state === "loading") return
74    if (mode === this.#mode) return
75    this.#mode = mode
76
77    if (mode === "video") return this.#enterVideoPreview({ flashOnError: true })
78    this.#stopStream()
79    this.#setState("idle")
80  }
81
82  releasePreview() {
83    if (this.#state !== "previewing") return
84    this.#stopStream()
85    this.#setState("idle")
86  }
87
88  async start() {
89    if (this.#state !== "idle" && this.#state !== "previewing") return
90    this.#setState("loading")
91    try {
92      if (!this.#stream) await this.#acquireStream()
93      this.#beginRecording()
94    } catch (e) {
95      console.error("[recording-manager] start failed:", e)
96      flash({ type: "error", message: "Could not access camera/microphone" })
97      this.#reset()
98    }
99  }
100
101  #beginRecording() {
102    const mimeType = this.#pickMimeType()
103    if (!mimeType) {
104      flash({ type: "error", message: "No supported recording format found" })
105      return this.#reset()
106    }
107
108    this.#uploader = new ChunkUploader({
109      chunkUrl: this.#chunkUrl,
110      completeUrl: this.#completeUrl,
111      bucketId: this.#bucketId,
112    })
113    this.#uploader.mimeType = mimeType
114
115    this.#mediaRecorder = new MediaRecorder(this.#stream, { mimeType })
116    this.#mediaRecorder.ondataavailable = (e) => {
117      if (e.data.size > 0) this.#uploader.upload(e.data)
118    }
119    this.#mediaRecorder.onstop = () => this.#onRecordingStopped()
120    this.#mediaRecorder.start(CHUNK_INTERVAL_MS)
121
122    this.#startTimer()
123    this.#setState("recording")
124    this.#updateBeforeUnload(true)
125  }
126
127  stop() {
128    if (this.#state !== "recording") return
129    if (this.#mediaRecorder?.state === "recording") this.#mediaRecorder.stop()
130    this.#stopTimer()
131    this.#stopStream()
132    this.#setState("saving")
133    this.#updateBeforeUnload(false)
134  }
135
136  discard() {
137    this.#updateBeforeUnload(false)
138    if (this.#mediaRecorder?.state === "recording") {
139      this.#mediaRecorder.onstop = null
140      this.#mediaRecorder.stop()
141    }
142    this.#reset()
143  }
144
145  async #onRecordingStopped() {
146    try {
147      await this.#uploader.finalize({ durationSeconds: this.#elapsed })
148      flash({ message: "Recording saved." })
149      const returnPath = this.#returnPath
150      this.#reset()
151      if (returnPath) return window.Turbo.visit(returnPath)
152      if (this.#mode === "video") await this.#enterVideoPreview({ flashOnError: false })
153    } catch (e) {
154      console.error("[recording-manager] save failed:", e)
155      flash({ type: "error", message: "Failed to save recording" })
156      this.#reset()
157    }
158  }
159
160  async #enterVideoPreview({ flashOnError }) {
161    this.#setState("loading")
162    try {
163      await this.#acquireStream()
164      this.#setState("previewing")
165    } catch (e) {
166      console.error("[recording-manager] preview failed:", e)
167      if (flashOnError) flash({ type: "error", message: "Could not access camera" })
168      this.#mode = "audio"
169      this.#setState("idle")
170    }
171  }
172
173  #reset() {
174    this.#stopStream()
175    this.#stopTimer()
176    this.#elapsed = 0
177    this.#mediaRecorder = null
178    this.#uploader = null
179    this.#updateBeforeUnload(false)
180    this.#setState("idle")
181  }
182
183  // --- Stream management ---
184
185  #selectedMicId = null
186  #selectedCameraId = null
187  #viewfinderEl = null
188  #waveformControllers = new Set()
189
190  async #acquireStream() {
191    this.#stopStream()
192
193    const constraints = { audio: this.#selectedMicId ? { deviceId: { exact: this.#selectedMicId } } : true }
194    if (this.#mode === "video") {
195      constraints.video = this.#selectedCameraId ? { deviceId: { exact: this.#selectedCameraId } } : { facingMode: "user" }
196    }
197
198    this.#stream = await navigator.mediaDevices.getUserMedia(constraints)
199    if (this.#viewfinderEl) this.#viewfinderEl.srcObject = this.#stream
200    this.#setupAnalyser()
201    this.#dispatch("devices-available", { devices: await navigator.mediaDevices.enumerateDevices() })
202  }
203
204  #stopStream() {
205    this.#waveformControllers.forEach((c) => c.stop())
206    if (this.#audioContext) {
207      this.#audioContext.close()
208      this.#audioContext = null
209      this.#analyser = null
210    }
211    this.#stream?.getTracks().forEach((t) => t.stop())
212    this.#stream = null
213    if (this.#viewfinderEl) this.#viewfinderEl.srcObject = null
214  }
215
216  #setupAnalyser() {
217    if (!this.#stream) return
218    this.#audioContext = new AudioContext()
219    const source = this.#audioContext.createMediaStreamSource(this.#stream)
220    this.#analyser = this.#audioContext.createAnalyser()
221    this.#analyser.fftSize = 256
222    source.connect(this.#analyser)
223    this.#waveformControllers.forEach((c) => c.start(this.#analyser))
224  }
225
226  #changeMic(deviceId) {
227    this.#selectedMicId = deviceId
228  }
229
230  #changeCamera(deviceId) {
231    this.#selectedCameraId = deviceId
232  }
233
234  #attachViewfinder(el) {
235    this.#viewfinderEl = el
236    if (this.#stream) el.srcObject = this.#stream
237  }
238
239  #detachViewfinder() {
240    if (this.#viewfinderEl) this.#viewfinderEl.srcObject = null
241    this.#viewfinderEl = null
242  }
243
244  #attachWaveform(controller) {
245    this.#waveformControllers.add(controller)
246    if (this.#analyser) controller.start(this.#analyser)
247  }
248
249  #detachWaveform(controller) {
250    controller.stop()
251    this.#waveformControllers.delete(controller)
252  }
253
254  // --- Timer ---
255
256  #startTimer() {
257    this.#elapsed = 0
258    this.#timerInterval = setInterval(() => {
259      this.#elapsed++
260      this.#dispatch("timer-tick", { elapsed: this.#elapsed, text: formatTime(this.#elapsed, { padMinutes: true }) })
261      if (this.#elapsed >= MAX_DURATION_SECONDS) this.stop()
262    }, 1000)
263  }
264
265  #stopTimer() {
266    if (this.#timerInterval) clearInterval(this.#timerInterval)
267    this.#timerInterval = null
268  }
269
270  // --- Nav Indicator ---
271
272  #renderIndicators() {
273    const isRecording = this.#state === "recording"
274    const onCapturesPage = window.location.pathname.includes("/captures")
275    const showIndicator = isRecording && !onCapturesPage
276    const path = isRecording ? (this.#capturesPath || this.#buildCapturesPath()) : null
277
278    for (const link of document.querySelectorAll('.navbar a[href*="/captures"]')) {
279      link.classList.toggle("recording-active-icon", showIndicator)
280      if (path) link.href = path
281    }
282    for (const link of document.querySelectorAll('nav a[href*="/captures"]')) {
283      link.classList.toggle("recording-active-icon", showIndicator)
284      if (path) link.href = path
285    }
286  }
287
288  #buildCapturesPath() {
289    const meta = document.querySelector('meta[name="current-studio-id"]')
290    if (!meta) return null
291    const base = `/studios/${meta.content}/captures`
292    return this.#bucketId ? `${base}?bucket_id=${this.#bucketId}` : base
293  }
294
295  // --- State & events ---
296
297  #setState(state) {
298    this.#state = state
299    this.#dispatch("state-changed")
300    this.#renderIndicators()
301  }
302
303  #dispatch(name, detail = {}) {
304    document.dispatchEvent(new CustomEvent(`recording-manager:${name}`, {
305      detail: { state: this.#state, mode: this.#mode, bucketId: this.#bucketId, elapsed: this.#elapsed, ...detail },
306    }))
307  }
308
309  // --- Helpers ---
310
311  #updateBeforeUnload(shouldBeActive) {
312    if (shouldBeActive === this.#beforeUnloadActive) return
313    this.#beforeUnloadActive = shouldBeActive
314    if (shouldBeActive) window.addEventListener("beforeunload", this.#warnBeforeUnload)
315    else window.removeEventListener("beforeunload", this.#warnBeforeUnload)
316  }
317
318  #warnBeforeUnload = (e) => { e.preventDefault(); e.returnValue = "" }
319
320  #pickMimeType() {
321    const types = this.#mode === "video" ? PREFERRED_VIDEO_MIME_TYPES : PREFERRED_AUDIO_MIME_TYPES
322    return types.find((t) => MediaRecorder.isTypeSupported(t)) || null
323  }
324}
325
326new RecordingManager()

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.