Move recordings UI to Livewire pages with Flux components.

Replace controller-driven Blade views with full-page Livewire index/show/create flows so Flux tables, toasts, and uploads work natively.
This commit is contained in:
ben
2026-08-12 19:17:52 +02:00
parent bfcfc12f58
commit 34ccf0c32b
37 changed files with 1482 additions and 1103 deletions
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Actions;
use App\Models\Recording;
use App\Models\User;
use App\Services\Mp3MetadataService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class StoreUploadedRecordings
{
public function __construct(private Mp3MetadataService $metadata) {}
/**
* Persist uploaded audio files and queue transcription.
*
* @param list<UploadedFile> $files
* @return array{
* recordings: list<Recording>,
* skipped_duplicates: int,
* message: string,
* }
*/
public function handle(User $user, array $files, ?string $titleOverride = null): array
{
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| $user->recordings()->where('content_hash', $hash)->exists()
|| $user->recordings()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && filled($titleOverride)
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($user, $file, $hash, $title);
}
$message = $this->message(count($recordings), $skippedDuplicates);
return [
'recordings' => $recordings,
'skipped_duplicates' => $skippedDuplicates,
'message' => $message,
];
}
private function storeUploadedRecording(
User $user,
UploadedFile $file,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $this->metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
private function message(int $savedCount, int $skippedDuplicates): string
{
if ($savedCount === 0 && $skippedDuplicates > 0) {
return $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.";
}
if ($savedCount === 0) {
return 'No valid audio files were uploaded.';
}
$message = $savedCount === 1
? 'Recording uploaded — transcription queued.'
: $savedCount.' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
return $message;
}
}
@@ -1,26 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CancelTranscriptionController extends Controller
{
/**
* Stop an in-progress or queued transcription.
*/
public function __invoke(Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
if (! $recording->isTranscribing()) {
return back()->with('error', 'No transcription is currently running.');
}
$recording->cancelTranscription();
return back()->with('success', 'Transcription stopped.');
}
}
@@ -1,223 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreRecordingRequest;
use App\Models\Recording;
use App\Services\Mp3MetadataService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class RecordingController extends Controller
{
/**
* Display a listing of recordings.
*/
public function index(Request $request): View
{
$user = $request->user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$query = $user->recordings()->latest();
if ($search = $request->string('q')->trim()->toString()) {
$query->search($search);
}
$recordings = $query->paginate(20)->withQueryString();
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
return view('recordings.index', [
'recordings' => $recordings,
'search' => $search ?? '',
'pendingCount' => $pendingCount,
]);
}
/**
* Show the upload form.
*/
public function create(Request $request): View
{
$existingFingerprints = $request->user()->recordings()
->get(['original_filename', 'file_size_bytes'])
->map(fn (Recording $recording) => $this->uploadFingerprint(
$recording->original_filename,
(int) $recording->file_size_bytes,
))
->unique()
->values()
->all();
return view('recordings.create', [
'existingFingerprints' => $existingFingerprints,
]);
}
/**
* Store one or more uploaded recordings.
*/
public function store(StoreRecordingRequest $request, Mp3MetadataService $metadata): RedirectResponse
{
/** @var list<UploadedFile> $files */
$files = array_values(array_filter(
$request->file('audio', []),
fn ($file) => $file instanceof UploadedFile,
));
$user = $request->user();
$titleOverride = $request->string('title')->trim()->toString();
$recordings = [];
$skippedDuplicates = 0;
$seenHashes = [];
foreach ($files as $file) {
$hash = hash_file('sha256', $file->getRealPath());
if ($hash === false) {
continue;
}
if (
isset($seenHashes[$hash])
|| $user->recordings()->where('content_hash', $hash)->exists()
|| $user->recordings()
->where('original_filename', $file->getClientOriginalName())
->where('file_size_bytes', $file->getSize() ?: 0)
->exists()
) {
$skippedDuplicates++;
continue;
}
$seenHashes[$hash] = true;
$title = count($files) === 1 && $titleOverride !== ''
? $titleOverride
: null;
$recordings[] = $this->storeUploadedRecording($request, $file, $metadata, $hash, $title);
}
if ($recordings === [] && $skippedDuplicates > 0) {
return redirect()
->route('recordings.create')
->with('error', $skippedDuplicates === 1
? 'That file is already uploaded — nothing new was saved.'
: "All {$skippedDuplicates} files were duplicates — nothing new was saved.");
}
if ($recordings === []) {
return redirect()
->route('recordings.create')
->with('error', 'No valid audio files were uploaded.');
}
$message = count($recordings) === 1
? 'Recording uploaded — transcription queued.'
: count($recordings).' recordings uploaded — transcription queued.';
if ($skippedDuplicates > 0) {
$message .= $skippedDuplicates === 1
? ' Skipped 1 duplicate.'
: " Skipped {$skippedDuplicates} duplicates.";
}
if (count($recordings) === 1) {
return redirect()
->route('recordings.show', $recordings[0])
->with('success', $message);
}
return redirect()
->route('recordings.index')
->with('success', $message);
}
/**
* Display the specified recording.
*/
public function show(Recording $recording): View
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
return view('recordings.show', compact('recording'));
}
/**
* Remove the specified recording.
*/
public function destroy(Recording $recording): RedirectResponse
{
Gate::authorize('delete', $recording);
$recording->deleteFile();
$recording->delete();
return redirect()
->route('recordings.index')
->with('success', 'Recording deleted.');
}
/**
* Persist a single uploaded audio file as a recording.
*/
private function storeUploadedRecording(
Request $request,
UploadedFile $file,
Mp3MetadataService $metadata,
string $contentHash,
?string $titleOverride = null,
): Recording {
$path = $file->store('recordings', 'local');
$absolutePath = Storage::disk('local')->path($path);
$tags = $metadata->extract($absolutePath);
$title = $titleOverride
?: ($tags['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME));
$recording = Recording::create([
'user_id' => $request->user()->id,
'title' => $title,
'original_filename' => $file->getClientOriginalName(),
'file_path' => $path,
'duration_seconds' => $tags['duration_seconds'],
'recorded_at' => $tags['recorded_at'],
'artist' => $tags['artist'],
'album' => $tags['album'],
'file_size_bytes' => $file->getSize() ?: 0,
'content_hash' => $contentHash,
'transcription_status' => 'pending',
'transcription_driver' => 'local',
]);
$recording->queueLocalTranscription();
return $recording->fresh();
}
/**
* Client-side fingerprint for name + size duplicate checks before upload.
*/
private function uploadFingerprint(string $filename, int $sizeBytes): string
{
return strtolower($filename).':'.$sizeBytes;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StreamRecordingController extends Controller
{
/**
* Stream the recording audio for in-browser playback.
*/
public function __invoke(Recording $recording): StreamedResponse
{
Gate::authorize('view', $recording);
abort_unless(
$recording->file_path && Storage::disk('local')->exists($recording->file_path),
404,
);
return Storage::disk('local')->response(
$recording->file_path,
$recording->original_filename,
[
'Content-Type' => $recording->audioMimeType(),
'Accept-Ranges' => 'bytes',
],
'inline',
);
}
}
@@ -1,25 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\TranscribeRecordingRequest;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class TranscribeController extends Controller
{
/**
* Queue local faster-whisper transcription for the recording.
*
* Always allowed: stops any current run first, then starts a new one.
*/
public function __invoke(TranscribeRecordingRequest $request, Recording $recording): RedirectResponse
{
Gate::authorize('transcribe', $recording);
$recording->queueLocalTranscription();
return back()->with('success', 'Transcription started. Progress updates below.');
}
}
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Recording;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TranscribePendingController extends Controller
{
/**
* Queue local transcription for recordings that still need a transcript.
*/
public function __invoke(Request $request): RedirectResponse
{
$queued = 0;
$request->user()->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->orderBy('id')
->each(function (Recording $recording) use (&$queued): void {
if ($recording->hasActiveTranscriptionJob()) {
return;
}
$recording->queueLocalTranscription();
$queued++;
});
if ($queued === 0) {
return back()->with('error', 'No recordings need transcription right now.');
}
return back()->with(
'success',
$queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
);
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TranscribeRecordingRequest extends FormRequest
{
public function authorize(): bool
{
$recording = $this->route('recording');
return $recording !== null && $this->user()?->can('transcribe', $recording) === true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Livewire\Recordings;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Upload recording')]
class Create extends Component
{
public function render(): View
{
return view('livewire.recordings.create');
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('Recordings')]
class Index extends Component
{
use WithPagination;
#[Url(as: 'q', history: true)]
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function queuePending(): void
{
$queued = 0;
Auth::user()->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->orderBy('id')
->each(function (Recording $recording) use (&$queued): void {
if ($recording->hasActiveTranscriptionJob()) {
return;
}
$recording->queueLocalTranscription();
$queued++;
});
if ($queued === 0) {
Flux::toast(text: 'No recordings need transcription right now.', variant: 'danger');
return;
}
Flux::toast(
text: $queued === 1
? 'Queued 1 recording for transcription.'
: "Queued {$queued} recordings for transcription.",
variant: 'success',
);
}
public function render(): View
{
$user = Auth::user();
$user->recordings()
->whereIn('transcription_status', ['pending', 'processing'])
->orderBy('id')
->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription());
$query = $user->recordings()->latest();
$search = trim($this->search);
if ($search !== '') {
$query->search($search);
}
$recordings = $query->paginate(20);
$pendingCount = $user->recordings()
->whereIn('transcription_status', ['pending', 'failed', 'cancelled'])
->get()
->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob())
->count();
return view('livewire.recordings.index', [
'recordings' => $recordings,
'search' => $search,
'pendingCount' => $pendingCount,
]);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Recordings;
use App\Models\Recording;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.app')]
class Show extends Component
{
public Recording $recording;
public function mount(Recording $recording): void
{
Gate::authorize('view', $recording);
$recording->recoverOrphanedTranscription();
$recording->refresh();
$this->recording = $recording;
}
public function startTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
$this->recording->queueLocalTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription started. Progress updates below.', variant: 'success');
}
public function cancelTranscription(): void
{
Gate::authorize('transcribe', $this->recording);
if (! $this->recording->isTranscribing()) {
Flux::toast(text: 'No transcription is currently running.', variant: 'danger');
return;
}
$this->recording->cancelTranscription();
$this->recording->refresh();
Flux::toast(text: 'Transcription stopped.', variant: 'success');
}
public function delete(): mixed
{
Gate::authorize('delete', $this->recording);
$this->recording->deleteFile();
$this->recording->delete();
session()->flash('success', 'Recording deleted.');
return $this->redirect(route('recordings.index'), navigate: true);
}
public function render(): View
{
return view('livewire.recordings.show')
->title($this->recording->title);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Livewire;
use App\Actions\StoreUploadedRecordings;
use App\Http\Requests\StoreRecordingRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\File;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class UploadRecordings extends Component
{
use WithFileUploads;
/**
* @var list<TemporaryUploadedFile>
*/
public array $audio = [];
public string $title = '';
public bool $saving = false;
public function updatedAudio(): void
{
if ($this->saving || $this->audio === []) {
return;
}
$this->save();
}
public function save(?StoreUploadedRecordings $store = null): mixed
{
if ($this->saving) {
return null;
}
$this->saving = true;
$store ??= app(StoreUploadedRecordings::class);
$this->validate([
'audio' => ['required', 'array', 'min:1', 'max:50'],
'audio.*' => [
'required',
File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'),
],
'title' => ['nullable', 'string', 'max:255'],
], [
'audio.required' => 'Please choose at least one audio file to upload.',
'audio.min' => 'Please choose at least one audio file to upload.',
'audio.max' => 'You can upload at most 50 files at once.',
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
]);
$result = $store->handle(
Auth::user(),
$this->audio,
filled($this->title) ? $this->title : null,
);
$this->audio = [];
$this->title = '';
$this->saving = false;
if ($result['recordings'] === []) {
session()->flash('error', $result['message']);
return $this->redirect(route('recordings.create'), navigate: true);
}
session()->flash('success', $result['message']);
if (count($result['recordings']) === 1) {
return $this->redirect(
route('recordings.show', $result['recordings'][0]),
navigate: true,
);
}
return $this->redirect(route('recordings.index'), navigate: true);
}
public function render()
{
return view('livewire.upload-recordings');
}
}
+20
View File
@@ -444,6 +444,26 @@ class Recording extends Model
return Storage::disk('local')->path($this->file_path);
}
/**
* MIME type for browser audio playback based on the original filename.
*/
public function audioMimeType(): string
{
$extension = strtolower(pathinfo((string) $this->original_filename, PATHINFO_EXTENSION));
return match ($extension) {
'mp3', 'mpga', 'mpeg' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg', 'oga' => 'audio/ogg',
'flac' => 'audio/flac',
'm4a', 'mp4', 'aac' => 'audio/mp4',
'webm' => 'audio/webm',
'wma' => 'audio/x-ms-wma',
'aiff', 'aif' => 'audio/aiff',
default => 'application/octet-stream',
};
}
/**
* Delete the audio file from storage.
*/