Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
323 lines
8.4 KiB
JavaScript
323 lines
8.4 KiB
JavaScript
/**
|
|
* 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] || {};
|
|
},
|
|
};
|
|
}
|