1import { Controller } from '@hotwired/stimulus'; 2 3/** 4 * Content Studio â Camera recorder. 5 * 6 * Flow: 7 * click Launch -> POST /recording-room (server mints Daily.co room + 8 * presigned PUT URL) 9 * -> if Daily SDK loaded + room URL returned, use DailyIframe local-mode 10 * recording (captures host + guests composite) 11 * -> else fall back to plain getUserMedia + MediaRecorder (solo only) 12 * click Stop -> get Blob -> PUT to presigned URL -> POST /recording-uploaded 13 * -> dispatches recorder:uploaded CustomEvent (no page redirect â 14 * studio-takes_controller.js appends the new take row instead) 15 * 16 * Also dispatches: 17 * recorder:started â when recording begins (studio-prompter starts scroll) 18 * recorder:stopped â when recording stops (studio-prompter pauses scroll) 19 * 20 * Listens for: 21 * takes:newTake â reset to idle so user can record another take 22 * 23 * Per .claude/rules/javascript.md the controller is MANUALLY REGISTERED in 24 * assets/bootstrap.js. Auto-discovery is unreliable in this project. 25 * 26 * No native confirm() / alert() - failures show inline status text per 27 * .claude/rules/javascript.md and the 2026-06-05 native-dialogs migration. 28 */ 29export default class extends Controller { 30 static values = { 31 projectId: Number, 32 uploadedUrl: String, 33 token: String, 34 }; 35 static targets = [ 36 'placeholder', 'frame', 'status', 'stopBtn', 37 // New targets for recording studio UI 38 'recOverlay', 'timer', 'waveform', 'pauseBtn', 39 'cameraSelect', 'micSelect', 40 'cameraStatus', 'micStatus', 'micLabel', 'micLevel', 41 ]; 42 43 connect() { 44 this.callFrame = null; 45 this.stream = null; 46 this.recorder = null; 47 this.chunks = []; 48 this.uploadUrl = null; 49 this.cdnPath = null; 50 this.startedAt = null; 51 this._timerRaf = null; 52 this._audioCtx = null; 53 this._analyser = null; 54 this._waveRaf = null; 55 56 this._checkPermissionState(); 57 this._enumerateDevices(); 58 59 this._onNewTake = () => this._resetToIdle(); 60 document.addEventListener('takes:newTake', this._onNewTake); 61 } 62 63 disconnect() { 64 this._cleanup(); 65 document.removeEventListener('takes:newTake', this._onNewTake); 66 } 67 68 // ââ Actions âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 69 70 async start(event) { 71 event?.preventDefault(); 72 this._setStatus('Preparing recorderâ¦'); 73 74 let payload; 75 try { 76 const fd = new FormData(); 77 fd.append('_token', this.tokenValue); 78 const res = await fetch(this._roomUrl(), { method: 'POST', body: fd, credentials: 'same-origin' }); 79 if (!res.ok) throw new Error(`HTTP ${res.status}`); 80 payload = await res.json(); 81 } catch (err) { 82 this._setStatus(`Could not prepare recorder: ${err.message}`, true); 83 return; 84 } 85 86 this.uploadUrl = payload.upload_url; 87 this.cdnPath = payload.cdn_path; 88 89 if (!this.uploadUrl) { 90 this._setStatus('Server did not return an upload URL.', true); 91 return; 92 } 93 94 if (payload.daily_room_url && window.DailyIframe) { 95 try { 96 await this._startDaily(payload.daily_room_url, payload.daily_token); 97 return; 98 } catch (err) { 99 this._setStatus(`Daily.co unavailable, using browser recorder: ${err.message}`); 100 } 101 } 102 103 await this._startMediaRecorder(); 104 } 105 106 async stop(event) { 107 event?.preventDefault(); 108 if (this.callFrame) { 109 try { await this.callFrame.stopRecording(); } catch (_) {} 110 } 111 if (this.recorder && this.recorder.state !== 'inactive') { 112 this.recorder.stop(); 113 } 114 this._dispatch('recorder:stopped'); 115 this._stopTimer(); 116 this._stopWaveform(); 117 } 118 119 pauseResume(event) { 120 event?.preventDefault(); 121 if (!this.recorder) return; 122 if (this.recorder.state === 'recording') { 123 this.recorder.pause(); 124 if (this.hasPauseBtnTarget) this.pauseBtnTarget.title = 'Re
124sume'; 125 } else if (this.recorder.state === 'paused') { 126 this.recorder.resume(); 127 if (this.hasPauseBtnTarget) this.pauseBtnTarget.title = 'Pause'; 128 } 129 } 130 131 restart(event) { 132 event?.preventDefault(); 133 this._resetToIdle(); 134 } 135 136 async switchCamera() { 137 if (this.recorder?.state === 'recording') return; // don't switch mid-take 138 await this._restartStream(); 139 } 140 141 async switchMic() { 142 if (this.recorder?.state === 'recording') return; 143 await this._restartStream(); 144 } 145 146 // ââ Recording paths âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 147 148 async _startDaily(roomUrl, token) { 149 const container = this.hasFrameTarget ? this.frameTarget : this.element; 150 this.callFrame = window.DailyIframe.createFrame(container, { 151 iframeStyle: { width: '100%', height: '420px', border: '0', borderRadius: '8px' },
152 showLeaveButton: false, 153 }); 154 155 this.callFrame.on('recording-data', (ev) => { 156 if (ev?.data instanceof Blob) this._uploadBlob(ev.data); 157 }); 158 this.callFrame.on('recording-error', (ev) => { 159 this._setStatus(`Daily recording error: ${ev?.errorMsg || 'unknown'}`, true); 160 }); 161 162 await this.callFrame.join({ url: roomUrl, token: token || undefined }); 163 await this.callFrame.startRecording({ type: 'local' }); 164 this._onRecordingBegan(); 165 } 166 167 async _startMediaRecorder() { 168 const cameraId = this.hasCameraSelectTarget ? this.cameraSelectTarget.value : undefined; 169 const micId = this.hasMicSelectTarget ? this.micSelectTarget.value : undefined; 170 171 try { 172 this.stream = await navigator.mediaDevices.getUserMedia({ 173 video: cameraId ? { deviceId: { exact: cameraId }, width: 1280, height: 720 } : { width: 1280, height: 720 }, 174 audio: micId ? { deviceId: { exact: micId } } : true, 175 }); 176 } catch (err) { 177 if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { 178 this._showPermissionDeniedHelp(); 179 } else { 180 this._setStatus( 181 err.name === 'NotFoundError' 182 ? 'No camera or microphone found. Please connect a device and try again.' 183 : `Could not access camera/mic: ${err.message}`, 184 true 185 ); 186 } 187 return; 188 } 189 190 // Live preview 191 const container = this.hasFrameTarget ? this.frameTarget : this.element; 192 container.innerHTML = ''; 193 const video = Object.assign(document.createElement('video'), { 194 autoplay: true, muted: true, playsInline: true, 195 }); 196 video.style.cssText = 'width:100%;height:100%;object-fit:cover;'; 197 video.srcObject = this.stream; 198 container.appendChild(video); 199 200 const mimeType = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'] 201 .find(m => window.MediaRecorder?.isTypeSupported?.(m)) || ''; 202 this.recorder = new MediaRecorder(this.stream, mimeType ? { mimeType } : undefined); 203 this.chunks = []; 204 this.recorder.ondataavailable = (e) => { if (e.data?.size > 0) this.chunks.push(e.data); }; 205 this.recorder.onstop = () => { 206 this._uploadBlob(new Blob(this.chunks, { type: this.recorder.mimeType || 'video/webm' })); 207 }; 208 this.recorder.start(1000); 209 210 this._initAudio(this.stream); 211 this._onRecordingBegan(); 212 } 213 214 _onRecordingBegan() { 215 this.startedAt = Date.now(); 216 this._setStatus(''); 217 if (this.hasPlaceholderTarget) this.placeholderTarget.classList.add('hidden'); 218 if (this.hasRecOverlayTarget) this.recOverlayTarget.classList.remove('hidden'); 219 if (this.hasStopBtnTarget) this.stopBtnTarget.classList.remove('hidden'); 220 if (this.hasPauseBtnTarget) this.pauseBtnTarget.classList.remove('hidden'); 221 if (this.hasCameraStatusTarget) { 222 this.cameraStatusTarget.classList.remove('text-base-content/40'); 223 this.cameraStatusTarget.classList.add('text-success'); 224 } 225 this._startTimer(); 226 this._dispatch('recorder:started'); 227 } 228 229 // ââ Upload ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 230 231 async _uploadBlob(blob) { 232 this._setStatus(`Uploading ${(blob.size / 1024 / 1024).toFixed(1)} MBâ¦`); 233 try { 234 const putRes = await fetch(this.uploadUrl, { 235 method: 'PUT', 236 headers: { 'Content-Type': blob.type || 'video/webm' }, 237 body: blob, 238 }); 239 if (!putRes.ok) throw new Error(`Spaces PUT failed: HTTP ${putRes.status}`); 240 } catch (err) { 241 this._setStatus(`Upload failed: ${err.message}`, true); 242 return; 243 } 244 245 const duration = this.startedAt ? (Date.now() - this.startedAt) / 1000 : 0; 246 const fd = new FormData(); 247 fd.append('_token', this.tokenValue); 248 fd.append('video_cdn_path', this.cdnPath || ''); 249 fd.append('duration', String(duration)); 250 251 let json; 252 try { 253 const res = await fetch(this.uploadedUrlValue, { method: 'POST', body: fd, credentials: 'same-origin' }); 254 json = await res.json(); 255 if (!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`); 256 } catch (err) { 257 this._setStatus(`Server save failed: ${err.message}`, true); 258 return; 259 } 260 261 this._setStatus('Take saved â record another or select a take below.'); 262 this._cleanup(); 263 this._resetToIdle(); 264 265 // Notify takes list â no page redirect 266 this._dispatch('recorder:uploaded', { 267 takeId: json.take?.id, 268 duration: json.take?.duration, 269 recordedAt: json.take?.recorded_at, 270 cdnPath: this.cdnPath, 271 }); 272 } 273 274 // ââ Timer âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 275 276 _startTimer() { 277 this._stopTimer(); 278 const tick = () => { 279 if (!this.hasTimerTarget) return; 280 const s = Math.floor((Date.now() - this.startedAt) / 1000); 281 const mm = String(Math.floor(s / 60)).padStart(2, '0'); 282 const ss = String(s % 60).padStart(2, '0'); 283 this.timerTarget.textContent = `${mm}:${ss}`; 284 this._timerRaf = requestAnimationFrame(tick); 285 }; 286 this._timerRaf = requestAnimationFrame(tick); 287 } 288 289 _stopTimer() { 290 if (this._timerRaf) cancelAnimationFrame(this._timerRaf); 291 this._timerRaf = null; 292 } 293 294 // ââ Audio visualiser ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 295 296 _initAudio(stream) { 297 try { 298 this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 299 this._analyser = this._audioCtx.createAnalyser(); 300 this._analyser.fftSize = 128; 301 this._audioCtx.createMediaStreamSource(stream).connect(this._analyser); 302 this._startWaveform(); 303 this._startMicLevel(); 304 } catch (_) { 305 // Audio API unavailable â silent fallback 306 } 307 } 308 309 _startWaveform() { 310 if (!this.hasWaveformTarget || !this._analyser) return; 311 const canvas = this.waveformTarget; 312 const ctx = canvas.getContext('2d'); 313 const hi = canvas.dataset.colorHi || '#f59e0b'; 314 const lo = canvas.dataset.colorLo || '#10b981'; 315 const buf = new Uint8Array(this._analyser.frequencyBinCount); 316 const draw = () => { 317 this._analyser.getByteFrequencyData(buf); 318 ctx.clearRect(0, 0, canvas.width, canvas.height); 319 const barW = canvas.width / buf.length * 2; 320 buf.forEach((val, i) => { 321 const h = (val / 255) * canvas.height;
322 ctx.fillStyle = i < buf.length / 2 ? hi : lo; 323 ctx.fillRect(i * barW, canvas.height - h, barW - 1, h); 324 }); 325 this._waveRaf = requestAnimationFrame(draw); 326 }; 327 this._waveRaf = requestAnimationFrame(draw); 328 } 329 330 _stopWaveform() { 331 if (this._waveRaf) cancelAnimationFrame(this._waveRaf); 332 this._waveRaf = null; 333 } 334 335 _startMicLevel() { 336 if (!this._analyser) return; 337 const buf = new Uint8Array(this._analyser.frequencyBinCount); 338 this._micLevelInterval = setInterval(() => { 339 this._analyser.getByteFrequencyData(buf); 340 const avg = buf.reduce((a, b) => a + b, 0) / buf.length; 341 const db = Math.round(20 * Math.log10((avg / 255) + 0.0001)); 342 if (this.hasMicLevelTarget) this.micLevelTarget.textContent = `${db} dB`; 343 if (this.hasMicStatusTarget) this.micStatusTarget.classList.remove('hidden'); 344 }, 200); 345 } 346 347 // ââ Device enumeration ââââââââââââââââââââââââââââââââââââââââââââââââââââ 348 349 async _enumerateDevices() { 350 if (!navigator.mediaDevices?.enumerateDevices) return; 351 try { 352 // Request a one-time permission so labels are returned 353 const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); 354 tmp.getTracks().forEach(t => t.stop()); 355 } catch (_) {} 356 357 const devices = await navigator.mediaDevices.enumerateDevices(); 358 this._populateSelect( 359 this.hasCameraSelectTarget ? this.cameraSelectTarget : null, 360 devices.filter(d => d.kind === 'videoinput') 361 ); 362 this._populateSelect( 363 this.hasMicSelectTarget ? this.micSelectTarget : null, 364 devices.filter(d => d.kind === 'audioinput') 365 ); 366 367 // Update mic label in hero row 368 if (this.hasMicLabelTarget && this.hasMicSelectTarget) { 369 const sel = this.micSelectTarget; 370 sel.addEventListener('change', () => { 371 this.micLabelTarget.textContent = sel.options[sel.selectedIndex]?.text || 'Mic'; 372 }); 373 this.micLabelTarget.textContent = sel.options[0]?.text || 'Mic'; 374 } 375 } 376 377 _populateSelect(select, devices) { 378 if (!select || devices.length === 0) return; 379 select.innerHTML = devices.map((d, i) => 380 `<option value="${this._escape(d.deviceId)}">${this._escape(d.label || `Device ${i + 1}`)}</option>` 381 ).join(''); 382 } 383 384 async _restartStream() { 385 if (this.stream) { 386 this.stream.getTracks().forEach(t => t.stop()); 387 this.stream = null; 388 } 389 if (this.hasFrameTarget) this.frameTarget.innerHTML = ''; 390 } 391 392 // ââ Helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 393 394 _resetToIdle() { 395 this._cleanup(); 396 if (this.hasPlaceholderTarget) this.placeholderTarget.classList.remove('hidden'); 397 if (this.hasRecOverlayTarget) this.recOverlayTarget.classList.add('hidden'); 398 if (this.hasStopBtnTarget) this.stopBtnTarget.classList.add('hidden'); 399 if (this.hasPauseBtnTarget) this.pauseBtnTarget.classList.add('hidden'); 400 if (this.hasTimerTarget) this.timerTarget.textContent = '00:00'; 401 if (this.hasFrameTarget) this.frameTarget.innerHTML = ''; 402 this._setStatus(''); 403 } 404 405 _showPermissionDeniedHelp() { 406 if (this.hasPlaceholderTarget) this.placeholderTarget.classList.add('hidden'); 407 if (this.element.querySelector('[data-perm-help]')) return; 408 const div = document.createElement('div'); 409 div.dataset.permHelp = '1'; 410 div.className = 'rounded-lg border border-error/30 bg-error/5 p-5 text-left space-y-3'; 411 div.innerHTML = ` 412 <p class="font-semibold text-sm text-error flex items-center gap-2"> 413 <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 414 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 415 d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/> 416 </svg> 417 Camera or microphone access was blocked 418 </p> 419 <p class="text-sm text-base-content/70">Your browser previously denied camera/mic access for this site. To record yourself:</p> 420 <ol class="text-sm text-base-content/70 list-decimal list-inside space-y-1"> 421 <li>Click the <strong>lock icon</strong> (or camera icon) in the address bar above</li> 422 <li>Set Camera and Microphone to <strong>Allow</strong></li> 423 <li>Reload this page</li> 424 </ol>`; 425 this.element.appendChild(div); 426 } 427 428 async _checkPermissionState() { 429 if (!navigator.permissions) return; 430 try { 431 const status = await navigator.permissions.query({ name: 'camera' }); 432 if (status.state === 'denied') this._showPermissionDeniedHelp(); 433 status.addEventListener('change', () => { 434 if (status.state === 'denied') { 435 this._showPermissionDeniedHelp(); 436 } else if (status.state === 'granted' && this.hasPlaceholderTarget) { 437 this.placeholderTarget.classList.remove('hidden'); 438 this.element.querySelector('[data-perm-help]')?.remove(); 439 } 440 }); 441 } catch (_) {} 442 } 443 444 _setStatus(text, isError = false) { 445 if (this.hasStatusTarget) { 446 this.statusTarget.textContent = text; 447 this.statusTarget.classList.toggle('text-error', isError); 448 } else if (text && typeof window.showToast === 'function') { 449 window.showToast(text, isError ? 'error' : 'info'); 450 } 451 } 452 453 _dispatch(eventName, detail = {}) { 454 this.element.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail })); 455 } 456 457 _cleanup() { 458 this._stopTimer(); 459 this._stopWaveform(); 460 clearInterval(this._micLevelInterval); 461 try { this.recorder?.stop(); } catch (_) {}
462 this.stream?.getTracks().forEach(t => t.stop()); 463 try { this.callFrame?.destroy(); } catch (_) {} 464 try { this._audioCtx?.close(); } catch (_) {} 465 this.callFrame = this.stream = this.recorder = this._audioCtx = this._analyser = null; 466 } 467 468 _roomUrl() { 469 return this.uploadedUrlValue.replace(/\/recording-uploaded$/, '/recording-room'); 470 } 471 472 _escape(str) { 473 return String(str) 474 .replace(/&/g, '&') 475 .replace(/</g, '<') 476 .replace(/>/g, '>') 477 .replace(/"/g, '"'); 478 } 479}
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.