/** * Shared helpers and Alpine components for live transcription updates via Reverb. */ const BADGE_COLORS = { done: 'teal', processing: 'amber', pending: 'amber', failed: 'red', cancelled: 'zinc', }; export function badgeColorFor(status) { return BADGE_COLORS[status] || 'zinc'; } /** @deprecated Use badgeColorFor — kept for any leftover callers */ export function badgeClassFor(status) { return badgeColorFor(status); } export function formatElapsed(seconds) { const total = Math.max(0, Math.round(Number(seconds) || 0)); const minutes = Math.floor(total / 60); const remain = total % 60; if (minutes === 0) { return remain + 's'; } return minutes + 'm ' + String(remain).padStart(2, '0') + 's'; } export function formatDuration(seconds) { const total = Math.max(0, Math.round(Number(seconds) || 0)); const minutes = Math.floor(total / 60); const remain = total % 60; return minutes + ':' + String(remain).padStart(2, '0'); } export function formatTimestamp(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } const pad = (n) => String(n).padStart(2, '0'); return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()); } function formatWordCount(count) { const n = Number(count) || 0; return n > 0 ? n.toLocaleString() : '—'; } function subscribeToRecording(recordingId, handler) { if (!window.Echo) { return () => {}; } const channelName = 'recording.' + recordingId; const channel = window.Echo.private(channelName); channel.listen('.RecordingTranscriptionUpdated', handler); return () => { window.Echo.leave(channelName); }; } function subscribeToRecordings(recordingIds, handler) { const leaveFns = recordingIds.map((id) => subscribeToRecording(id, handler)); return () => { leaveFns.forEach((leave) => leave()); }; } /** * Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate. */ export function transcriptionMonitor({ statusUrl, initial }) { return { statusUrl, status: { ...initial, badge_color: badgeColorFor(initial.status), }, pollError: null, tickTimer: null, leaveChannel: null, get badgeColor() { return badgeColorFor(this.status.status); }, get startButtonLabel() { if (this.status.is_active) { return 'Restart transcription'; } return this.status.has_transcript ? 'Re-transcribe' : 'Start transcription'; }, start() { this.leaveChannel = subscribeToRecording(this.status.id, (event) => { if (Number(event.id) !== Number(this.status.id)) { return; } this.applyPayload(event); }); if (this.status.is_active) { this.beginTick(); this.hydrateOnce(); } }, destroy() { this.stopTick(); if (this.leaveChannel) { this.leaveChannel(); this.leaveChannel = null; } }, applyPayload(payload) { const wasActive = this.status.is_active; this.status = { ...this.status, ...payload, badge_color: badgeColorFor(payload.status ?? this.status.status), }; this.pollError = null; if (this.status.is_active) { this.beginTick(); } else { this.stopTick(); } if (wasActive && !this.status.is_active && !this.status.has_transcript && this.status.status !== 'failed' && this.status.status !== 'cancelled') { window.location.reload(); } }, beginTick() { if (this.tickTimer) { return; } this.tickTimer = setInterval(() => this.tickElapsed(), 1000); }, stopTick() { if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } }, tickElapsed() { if (!this.status.is_active || this.status.elapsed_seconds == null) { return; } this.status.elapsed_seconds += 1; this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds); }, async hydrateOnce() { if (!this.statusUrl) { return; } try { const response = await fetch(this.statusUrl, { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Status request failed (' + response.status + ')'); } this.applyPayload(await response.json()); } catch (error) { this.pollError = error.message || 'Could not refresh progress.'; } }, formatElapsed, formatDuration, formatTimestamp, }; } /** * Index-page Alpine component: patch row status from Reverb events. */ export function recordingsIndex({ recordings, pendingCount }) { const byId = {}; for (const row of recordings) { byId[row.id] = row; } return { rows: byId, pendingCount: Number(pendingCount) || 0, leaveChannel: null, playingId: null, isPlaying: false, start() { this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => { this.applyPayload(event); }); }, destroy() { if (this.leaveChannel) { this.leaveChannel(); this.leaveChannel = null; } const player = this.$refs.player; if (player) { player.pause(); player.removeAttribute('src'); player.load(); } }, syncPlayer() { const player = this.$refs.player; this.isPlaying = Boolean(player && !player.paused && !player.ended); if (player?.ended) { this.playingId = null; } }, isPlayingRow(id) { return this.playingId === id && this.isPlaying; }, togglePlay(id, url) { const player = this.$refs.player; if (!player) { return; } if (this.playingId === id && this.isPlaying) { player.pause(); return; } if (this.playingId !== id) { player.src = url; this.playingId = id; } player.play().catch(() => { this.playingId = null; this.isPlaying = false; }); }, applyPayload(payload) { const id = payload.id; if (!this.rows[id]) { return; } const previousStatus = this.rows[id].status; const next = { ...this.rows[id], status: payload.status, status_label: payload.status_label, progress: payload.progress, percent: payload.percent, is_active: payload.is_active, word_count: payload.word_count ?? this.rows[id].word_count, word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count), badge_color: badgeColorFor(payload.status), }; this.rows[id] = next; const wasQueueable = ['pending', 'failed', 'cancelled'].includes(previousStatus); const isQueueable = ['pending', 'failed', 'cancelled'].includes(payload.status); if (wasQueueable && !isQueueable) { this.pendingCount = Math.max(0, this.pendingCount - 1); } else if (!wasQueueable && isQueueable) { this.pendingCount += 1; } }, row(id) { return this.rows[id] || {}; }, }; }