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.