PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/studio-recorder_controller-FYoywim.js

js healthylifenetwork.com.au collected 2026-09-25 03:34:49 UTC 19,959 bytes, 479 lines download raw bytes

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, '&amp;')
475            .replace(/</g, '&lt;')
476            .replace(/>/g, '&gt;')
477            .replace(/"/g, '&quot;');
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.