Implement a new method for calculating elapsed transcription time in the Recording model. Enhance the TranscriptionService to handle local Whisper requests, including asynchronous processing and progress reporting. Update the recordings index view to display transcription progress percentage. Add unit tests for the new transcription service functionality.
242 lines
11 KiB
PHP
242 lines
11 KiB
PHP
<div
|
|
@if ($hasActiveTranscriptions)
|
|
wire:poll.2s.visible
|
|
@endif
|
|
x-data="recordingsIndex()"
|
|
x-init="
|
|
start();
|
|
return () => destroy();
|
|
"
|
|
>
|
|
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<flux:heading size="xl">Recordings</flux:heading>
|
|
<flux:text class="mt-1">Manage pocket-recorder audio and transcripts.</flux:text>
|
|
</div>
|
|
<div class="flex flex-col gap-2 sm:items-end">
|
|
<div class="flex items-end gap-2">
|
|
<flux:input
|
|
type="search"
|
|
wire:model.live.debounce.400ms="search"
|
|
placeholder="Search title, artist, transcript…"
|
|
class="min-w-[16rem]"
|
|
/>
|
|
</div>
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
@if ($pendingCount > 0)
|
|
<flux:button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
wire:click="queuePending"
|
|
>
|
|
Queue {{ $pendingCount }} pending
|
|
{{ $pendingCount === 1 ? 'transcription' : 'transcriptions' }}
|
|
</flux:button>
|
|
@endif
|
|
|
|
@if ($totalCount > 0)
|
|
<flux:modal.trigger name="delete-all-recordings">
|
|
<flux:button type="button" variant="danger" size="sm">
|
|
Delete all
|
|
</flux:button>
|
|
</flux:modal.trigger>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if ($totalCount > 0)
|
|
<flux:modal name="delete-all-recordings" class="max-w-md">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Delete all recordings?</flux:heading>
|
|
<flux:text class="mt-2">
|
|
This permanently removes
|
|
{{ $totalCount === 1 ? 'your 1 recording' : "all {$totalCount} recordings" }}
|
|
and their audio files. This cannot be undone.
|
|
</flux:text>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<flux:modal.close>
|
|
<flux:button variant="ghost">Cancel</flux:button>
|
|
</flux:modal.close>
|
|
<flux:button type="button" variant="danger" wire:click="deleteAll">
|
|
Delete all
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
@endif
|
|
|
|
@if ($recordings->isEmpty())
|
|
@if (filled($search))
|
|
<flux:card class="border-dashed py-16 text-center">
|
|
<flux:text>No recordings match “{{ $search }}”.</flux:text>
|
|
<div class="mt-4">
|
|
<flux:button variant="ghost" wire:click="$set('search', '')">Clear search</flux:button>
|
|
</div>
|
|
</flux:card>
|
|
@else
|
|
<livewire:upload-recordings :show-cancel="false" />
|
|
@endif
|
|
@else
|
|
<audio
|
|
x-ref="player"
|
|
class="hidden"
|
|
preload="none"
|
|
@play="syncPlayer()"
|
|
@pause="syncPlayer()"
|
|
@ended="playingId = null; syncPlayer()"
|
|
></audio>
|
|
|
|
<flux:table :paginate="$recordings">
|
|
<flux:table.columns>
|
|
<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-44"
|
|
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 class="max-w-xl">
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<flux:link
|
|
href="{{ route('recordings.show', $recording) }}"
|
|
wire:navigate
|
|
class="shrink-0 font-medium"
|
|
>
|
|
{{ $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 }}
|
|
</span>
|
|
@endif
|
|
</div>
|
|
</flux:table.cell>
|
|
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
|
|
<flux:table.cell>
|
|
<span class="tabular-nums">
|
|
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
|
|
</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell class="w-44 whitespace-nowrap">
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<x-transcription-status-badge
|
|
:status="$recording->transcription_status"
|
|
:label="$recording->transcriptionStatusLabel()"
|
|
/>
|
|
@if ($recording->transcription_status === 'processing' && $recording->transcription_percent !== null)
|
|
<span class="tabular-nums text-xs text-zinc-500 dark:text-zinc-400">
|
|
{{ $recording->transcription_percent }}%
|
|
</span>
|
|
@endif
|
|
</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
{{ $recording->created_at?->format('Y-m-d H:i') }}
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
<flux:modal.trigger name="delete-recording-{{ $recording->id }}">
|
|
<flux:button
|
|
type="button"
|
|
variant="danger"
|
|
size="sm"
|
|
square
|
|
aria-label="Delete {{ $recording->title }}"
|
|
>
|
|
<flux:icon.trash variant="micro" />
|
|
</flux:button>
|
|
</flux:modal.trigger>
|
|
|
|
<flux:modal name="delete-recording-{{ $recording->id }}" class="max-w-md">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Delete recording?</flux:heading>
|
|
<flux:text class="mt-2">
|
|
This permanently removes “{{ $recording->title }}” and its audio file.
|
|
</flux:text>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<flux:modal.close>
|
|
<flux:button variant="ghost">Cancel</flux:button>
|
|
</flux:modal.close>
|
|
<flux:button
|
|
type="button"
|
|
variant="danger"
|
|
wire:click="delete({{ $recording->id }})"
|
|
>
|
|
Delete
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforeach
|
|
</flux:table.rows>
|
|
</flux:table>
|
|
@endif
|
|
</div>
|