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.
This commit is contained in:
ben
2026-08-12 21:27:03 +02:00
parent 5f0f61995c
commit 8a66cf6f63
7 changed files with 344 additions and 67 deletions
+39 -9
View File
@@ -66,12 +66,16 @@ function subscribeToRecording(recordingId, handler) {
channel.listen('.RecordingTranscriptionUpdated', handler);
return () => {
window.Echo.leave(channelName);
// 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 {
@@ -82,6 +86,7 @@ export function transcriptionMonitor({ statusUrl, initial }) {
},
pollError: null,
tickTimer: null,
hydrateTimer: null,
leaveChannel: null,
get badgeColor() {
@@ -108,11 +113,13 @@ export function transcriptionMonitor({ statusUrl, initial }) {
if (this.status.is_active) {
this.beginTick();
this.hydrateOnce();
this.beginHydratePoll();
}
},
destroy() {
this.stopTick();
this.stopHydratePoll();
if (this.leaveChannel) {
this.leaveChannel();
@@ -131,12 +138,14 @@ export function transcriptionMonitor({ statusUrl, initial }) {
if (this.status.is_active) {
this.beginTick();
this.beginHydratePoll();
} else {
this.stopTick();
this.stopHydratePoll();
}
if (wasActive && !this.status.is_active
&& !this.status.has_transcript
if (wasActive && ! this.status.is_active
&& ! this.status.has_transcript
&& this.status.status !== 'failed'
&& this.status.status !== 'cancelled') {
window.location.reload();
@@ -158,26 +167,47 @@ export function transcriptionMonitor({ statusUrl, initial }) {
}
},
tickElapsed() {
if (!this.status.is_active || this.status.elapsed_seconds == null) {
beginHydratePoll() {
if (this.hydrateTimer || ! this.statusUrl) {
return;
}
this.status.elapsed_seconds += 1;
this.status.elapsed_human = formatElapsed(this.status.elapsed_seconds);
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) {
if (! this.statusUrl) {
return;
}
try {
const response = await fetch(this.statusUrl, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) {
if (! response.ok) {
throw new Error('Status request failed (' + response.status + ')');
}
@@ -92,39 +92,53 @@
<flux:table :paginate="$recordings">
<flux:table.columns>
<flux:table.column class="w-12"></flux:table.column>
<flux:table.column>Title</flux:table.column>
<flux:table.column>Duration</flux:table.column>
<flux:table.column>Words</flux:table.column>
<flux:table.column>Status</flux:table.column>
<flux:table.column>Uploaded</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'title'"
:direction="$sortDirection"
wire:click="sort('title')"
>
Title
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'duration'"
:direction="$sortDirection"
wire:click="sort('duration')"
>
Duration
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'words'"
:direction="$sortDirection"
wire:click="sort('words')"
>
Words
</flux:table.column>
<flux:table.column
class="w-36"
sortable
:sorted="$sortBy === 'status'"
:direction="$sortDirection"
wire:click="sort('status')"
>
Status
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'uploaded'"
:direction="$sortDirection"
wire:click="sort('uploaded')"
>
Uploaded
</flux:table.column>
<flux:table.column class="w-24"></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($recordings as $recording)
<flux:table.row wire:key="recording-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}">
<flux:table.cell>
<flux:button
type="button"
variant="ghost"
size="sm"
square
data-audio-url="{{ route('recordings.audio', $recording) }}"
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
>
<flux:icon.play
variant="micro"
x-show="! isPlayingRow({{ $recording->id }})"
/>
<flux:icon.pause
variant="micro"
x-show="isPlayingRow({{ $recording->id }})"
x-cloak
/>
</flux:button>
</flux:table.cell>
<flux:table.cell class="max-w-xl">
<div class="flex min-w-0 items-center gap-2">
<flux:link
@@ -134,6 +148,26 @@
>
{{ $recording->title }}
</flux:link>
<flux:button
type="button"
variant="ghost"
size="sm"
square
class="shrink-0"
data-audio-url="{{ route('recordings.audio', $recording) }}"
x-bind:aria-label="isPlayingRow({{ $recording->id }}) ? 'Pause' : 'Play'"
x-on:click="togglePlay({{ $recording->id }}, $el.dataset.audioUrl)"
>
<flux:icon.play
variant="micro"
x-show="! isPlayingRow({{ $recording->id }})"
/>
<flux:icon.pause
variant="micro"
x-show="isPlayingRow({{ $recording->id }})"
x-cloak
/>
</flux:button>
@if ($preview = $recording->transcriptFirstLine())
<span class="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400" title="{{ $preview }}">
{{ $preview }}
@@ -147,29 +181,11 @@
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</span>
</flux:table.cell>
<flux:table.cell class="whitespace-normal py-2">
<flux:table.cell class="w-36 whitespace-nowrap">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
/>
@if ($recording->transcription_status === 'pending' && filled($recording->transcription_progress))
<div
class="mt-1 max-w-[14rem] truncate text-xs text-zinc-500 dark:text-zinc-400"
title="{{ $recording->transcription_progress }}"
>
{{ $recording->transcription_progress }}
</div>
@elseif ($recording->transcription_status === 'processing' && filled($recording->transcription_progress))
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
title="{{ $recording->transcription_progress }}"
>
@if ($recording->transcription_percent)
{{ $recording->transcription_percent }}% ·
@endif
{{ $recording->transcription_progress }}
</div>
@endif
</flux:table.cell>
<flux:table.cell>
{{ $recording->created_at?->format('Y-m-d H:i') }}
@@ -1,13 +1,19 @@
<div
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="
start();
return () => destroy();
"
@if ($recording->isTranscribing())
wire:poll.2s.visible
@endif
>
<div
wire:key="transcription-ui-{{ $recording->id }}-{{ $recording->transcription_status }}-{{ $recording->transcription_percent }}-{{ md5((string) $recording->transcription_progress) }}-{{ $recording->transcribed_at?->timestamp }}"
x-data="transcriptionMonitor(@js([
'statusUrl' => route('recordings.transcription-status', $recording),
'initial' => $recording->transcriptionStatusPayload(),
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<flux:link href="{{ route('recordings.index') }}" wire:navigate class="text-sm"> Recordings</flux:link>
@@ -222,4 +228,5 @@
No transcript yet. Transcription starts automatically after upload, or use the button above.
</flux:text>
</flux:card>
</div>
</div>