Files
AndyTranscribe/resources/js/transcription.js
T
ben bfcfc12f58 Add Fortify auth, Flux dark mode, demo seed, and Docker hot reload.
Protect recordings per user, seed a demo login on container start, and bind-mount the app with Vite HMR for local Compose development.
2026-08-12 18:38:53 +02:00

263 lines
7.5 KiB
JavaScript

/**
* Shared helpers and Alpine components for live transcription updates via Reverb.
*/
const BADGE_CLASSES = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
};
export function badgeClassFor(status) {
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30';
}
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,
pollError: null,
tickTimer: null,
leaveChannel: null,
get badgeClass() {
return badgeClassFor(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 };
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,
start() {
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
this.applyPayload(event);
});
},
destroy() {
if (this.leaveChannel) {
this.leaveChannel();
this.leaveChannel = null;
}
},
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_class: badgeClassFor(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] || {};
},
};
}