/** * Shared helpers and Alpine components for live transcription updates via Reverb. */ const BADGE_COLORS = { done: 'teal', processing: 'amber', pending: 'zinc', 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 subscribeToRecording(recordingId, handler) { if (!window.Echo) { return () => {}; } const channelName = 'recording.' + recordingId; const channel = window.Echo.private(channelName); channel.listen('.RecordingTranscriptionUpdated', handler); return () => { // Prefer stopListening over leave() so a remount does not drop other subscribers. if (typeof channel.stopListening === 'function') { channel.stopListening('.RecordingTranscriptionUpdated'); } }; } /** * Show-page Alpine component: Echo push + local elapsed tick + optional status hydrate. * Livewire also listens on the user recordings channel and polls while active. */ export function transcriptionMonitor({ statusUrl, initial }) { return { statusUrl, status: { ...initial, badge_color: badgeColorFor(initial.status), }, pollError: null, tickTimer: null, hydrateTimer: 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(); this.beginHydratePoll(); } }, destroy() { this.stopTick(); this.stopHydratePoll(); 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(); this.beginHydratePoll(); } else { this.stopTick(); this.stopHydratePoll(); } 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; } }, beginHydratePoll() { if (this.hydrateTimer || ! this.statusUrl) { return; } this.hydrateTimer = setInterval(() => this.hydrateOnce(), 2000); }, stopHydratePoll() { if (this.hydrateTimer) { clearInterval(this.hydrateTimer); this.hydrateTimer = null; } }, tickElapsed() { if (! this.status.is_active || this.status.elapsed_seconds == null) { return; } const next = this.status.elapsed_seconds + 1; this.status = { ...this.status, elapsed_seconds: next, elapsed_human: formatElapsed(next), }; }, async hydrateOnce() { if (! this.statusUrl) { return; } try { const response = await fetch(this.statusUrl, { headers: { Accept: 'application/json' }, credentials: 'same-origin', }); 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: inline audio player only. * Status/progress refresh via Livewire Echo + wire:poll. */ export function recordingsIndex() { return { playingId: null, isPlaying: false, start() { // }, destroy() { 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; }); }, }; }