Files
AndyTranscribe/resources/js/transcription.js
T
ben 8a66cf6f63 Improve recordings list sorting and live show-page status updates.
Add sortable columns with a fixed status width, and keep the show page in sync via Echo, polling, and Alpine hydrate while transcription runs.
2026-08-12 21:27:03 +02:00

288 lines
7.3 KiB
JavaScript

/**
* 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;
});
},
};
}