From c17f8fb5060379641590d19656eda01d63c11b8a Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 20:14:35 +0200 Subject: [PATCH] Improve live recordings UI, upload flow, and default Flux theme. Refresh list status over Reverb with polling fallback, clarify queued vs processing, streamline upload empty states, and seed an admin login. --- .env.example | 21 ++- README.md | 12 +- app/Events/RecordingTranscriptionUpdated.php | 1 + app/Livewire/Recordings/Index.php | 55 +++++++ app/Livewire/UploadRecordings.php | 75 +++++----- app/Models/Recording.php | 2 +- database/seeders/DatabaseSeeder.php | 9 ++ resources/css/app.css | 7 +- resources/js/transcription.js | 75 +--------- .../views/components/disk-space-bar.blade.php | 6 +- .../transcription-status-badge.blade.php | 23 ++- resources/views/layouts/app.blade.php | 68 ++++----- resources/views/layouts/app/header.blade.php | 28 ++-- resources/views/layouts/auth/card.blade.php | 21 ++- resources/views/layouts/auth/simple.blade.php | 20 +-- resources/views/layouts/auth/split.blade.php | 26 ++-- .../livewire/recordings/create.blade.php | 4 - .../views/livewire/recordings/index.blade.php | 128 ++++++++++------- .../views/livewire/recordings/show.blade.php | 25 +++- .../livewire/upload-recordings.blade.php | 69 ++++++--- resources/views/partials/head.blade.php | 2 + routes/channels.php | 4 + tests/Feature/DemoUserSeederTest.php | 12 ++ .../Feature/RecordingDuplicateUploadTest.php | 16 +-- tests/Feature/RecordingUploadTest.php | 8 +- tests/Feature/Recordings/IndexTest.php | 135 ++++++++++++++++++ tests/Feature/TranscriptionBroadcastTest.php | 1 + .../Feature/UploadRecordingsLivewireTest.php | 3 +- 28 files changed, 523 insertions(+), 333 deletions(-) diff --git a/.env.example b/.env.example index b425773..679ef61 100644 --- a/.env.example +++ b/.env.example @@ -74,10 +74,16 @@ REVERB_SCHEME=http REVERB_SERVER_HOST=0.0.0.0 REVERB_SERVER_PORT=8080 +# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper) +APP_HOST_PORT=8080 +REVERB_HOST_PORT=8081 +WHISPER_HOST_PORT=8090 + VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" -VITE_REVERB_HOST="${REVERB_HOST}" -VITE_REVERB_PORT="${REVERB_PORT}" -VITE_REVERB_SCHEME="${REVERB_SCHEME}" +VITE_REVERB_HOST=localhost +# Browser WS port: use REVERB_HOST_PORT with Docker (8081), or REVERB_PORT for bare-metal reverb:start +VITE_REVERB_PORT="${REVERB_HOST_PORT}" +VITE_REVERB_SCHEME=http # AndyTranscribe / local faster-whisper LOCAL_WHISPER_URL=http://127.0.0.1:8090/v1 @@ -87,15 +93,6 @@ TRANSCRIPTION_TIMEOUT=600 # Must be greater than TRANSCRIPTION_TIMEOUT so long Whisper jobs are not re-queued mid-run DB_QUEUE_RETRY_AFTER=660 -# Host ports for docker compose (FrankenPHP app, Reverb WS, Whisper) -APP_HOST_PORT=8080 -REVERB_HOST_PORT=8081 -WHISPER_HOST_PORT=8090 - -# Browser-facing Reverb host/port (used at Vite build time in Docker) -VITE_REVERB_HOST=localhost -VITE_REVERB_SCHEME=http - # Demo user created on every container start (db:seed via entrypoint) SEED_USER_NAME="Demo User" SEED_USER_EMAIL=demo@example.com diff --git a/README.md b/README.md index 4949eba..a26f8b7 100644 --- a/README.md +++ b/README.md @@ -87,14 +87,14 @@ Whisper may take a minute or two while the model downloads. ### Demo login -Every container start runs `db:seed`, which ensures this user exists: +Every container start runs `db:seed`, which ensures these users exist: -| Field | Default | +| Email | Password | | --- | --- | -| Email | `demo@example.com` | -| Password | `password` | +| `demo@example.com` | `password` | +| `admin@example.com` | `password` | -Override with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`. +Override the demo user with `SEED_USER_NAME`, `SEED_USER_EMAIL`, and `SEED_USER_PASSWORD` in `.env`. ### 5. Open the app @@ -197,7 +197,7 @@ Use the GPU Whisper service instead of the CPU `whisper` service when you have a ## Usage -1. Open the app and **Log in** with the demo user (`demo@example.com` / `password`), or **Register** a new account. +1. Open the app and **Log in** with `admin@example.com` / `password` (or `demo@example.com` / `password`), or **Register** a new account. 2. Open **Recordings → Upload** and drop one or many audio files. 3. Transcription starts automatically (the `queue` service must be running). 4. Watch live progress on the list or detail page; stop or restart anytime. diff --git a/app/Events/RecordingTranscriptionUpdated.php b/app/Events/RecordingTranscriptionUpdated.php index 166b441..d48bc79 100644 --- a/app/Events/RecordingTranscriptionUpdated.php +++ b/app/Events/RecordingTranscriptionUpdated.php @@ -27,6 +27,7 @@ class RecordingTranscriptionUpdated implements ShouldBroadcastNow { return [ new PrivateChannel('recording.'.$this->recording->id), + new PrivateChannel('user.'.$this->recording->user_id.'.recordings'), ]; } diff --git a/app/Livewire/Recordings/Index.php b/app/Livewire/Recordings/Index.php index e003ca1..6922be1 100644 --- a/app/Livewire/Recordings/Index.php +++ b/app/Livewire/Recordings/Index.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Attributes\Url; use Livewire\Component; @@ -19,14 +20,30 @@ class Index extends Component { use WithPagination; + public int $userId; + #[Url(as: 'q', history: true)] public string $search = ''; + public function mount(): void + { + $this->userId = (int) Auth::id(); + } + public function updatedSearch(): void { $this->resetPage(); } + /** + * Re-render when any of this user's recordings broadcast a status change. + */ + #[On('echo-private:user.{userId}.recordings,.RecordingTranscriptionUpdated')] + public function onTranscriptionUpdated(): void + { + // + } + public function queuePending(): void { $queued = 0; @@ -69,6 +86,36 @@ class Index extends Component Flux::toast(text: 'Recording deleted.', variant: 'success'); } + public function deleteAll(): void + { + $deleted = 0; + + Auth::user()->recordings() + ->orderBy('id') + ->each(function (Recording $recording) use (&$deleted): void { + Gate::authorize('delete', $recording); + + $recording->deleteFile(); + $recording->delete(); + $deleted++; + }); + + $this->resetPage(); + + if ($deleted === 0) { + Flux::toast(text: 'No recordings to delete.', variant: 'danger'); + + return; + } + + Flux::toast( + text: $deleted === 1 + ? 'Deleted 1 recording.' + : "Deleted {$deleted} recordings.", + variant: 'success', + ); + } + public function render(): View { $user = Auth::user(); @@ -78,6 +125,8 @@ class Index extends Component ->orderBy('id') ->each(fn (Recording $recording) => $recording->recoverOrphanedTranscription()); + $totalCount = $user->recordings()->count(); + $query = $user->recordings()->latest(); $search = trim($this->search); @@ -94,10 +143,16 @@ class Index extends Component ->filter(fn (Recording $recording) => ! $recording->hasActiveTranscriptionJob()) ->count(); + $hasActiveTranscriptions = $user->recordings() + ->whereIn('transcription_status', ['pending', 'processing']) + ->exists(); + return view('livewire.recordings.index', [ 'recordings' => $recordings, 'search' => $search, 'pendingCount' => $pendingCount, + 'hasActiveTranscriptions' => $hasActiveTranscriptions, + 'totalCount' => $totalCount, ]); } } diff --git a/app/Livewire/UploadRecordings.php b/app/Livewire/UploadRecordings.php index f49625d..e3ba8d2 100644 --- a/app/Livewire/UploadRecordings.php +++ b/app/Livewire/UploadRecordings.php @@ -19,10 +19,10 @@ class UploadRecordings extends Component */ public array $audio = []; - public string $title = ''; - public bool $saving = false; + public bool $showCancel = true; + public function updatedAudio(): void { if ($this->saving || $this->audio === []) { @@ -40,49 +40,46 @@ class UploadRecordings extends Component $this->saving = true; - $store ??= app(StoreUploadedRecordings::class); + try { + $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.', - ]); + $this->validate([ + 'audio' => ['required', 'array', 'min:1', 'max:50'], + 'audio.*' => [ + 'required', + File::types(StoreRecordingRequest::AUDIO_EXTENSIONS)->max('2gb'), + ], + ], [ + '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, - ); + $result = $store->handle(Auth::user(), $this->audio); - $this->audio = []; - $this->title = ''; - $this->saving = false; + $this->audio = []; - if ($result['recordings'] === []) { - session()->flash('error', $result['message']); + if ($result['recordings'] === []) { + session()->flash('error', $result['message']); - return $this->redirect(route('recordings.create'), navigate: true); + 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); + } finally { + $this->saving = false; } - - 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() diff --git a/app/Models/Recording.php b/app/Models/Recording.php index c0168e1..5b9a480 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -135,7 +135,7 @@ class Recording extends Model 'ollama_url' => null, 'transcription_status' => 'pending', 'transcription_progress' => 'Queued — waiting to start…', - 'transcription_percent' => 5, + 'transcription_percent' => null, 'transcription_started_at' => now(), 'transcription_error' => null, // Keep the previous transcript until a new run succeeds. diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 62e06ad..d3f1e65 100755 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -29,6 +29,15 @@ class DatabaseSeeder extends Seeder ], ); + User::query()->updateOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin', + 'password' => 'password', + 'email_verified_at' => now(), + ], + ); + Recording::query() ->whereNull('user_id') ->update(['user_id' => $user->id]); diff --git a/resources/css/app.css b/resources/css/app.css index 406abdf..9981bdc 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,11 +8,8 @@ @source '../js'; @theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; - --color-accent: var(--color-teal-700); - --color-accent-content: var(--color-teal-700); - --color-accent-foreground: var(--color-white); + --font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; } @custom-variant dark (&:where(.dark, .dark *)); diff --git a/resources/js/transcription.js b/resources/js/transcription.js index e9855cd..78498b9 100644 --- a/resources/js/transcription.js +++ b/resources/js/transcription.js @@ -5,7 +5,7 @@ const BADGE_COLORS = { done: 'teal', processing: 'amber', - pending: 'amber', + pending: 'zinc', failed: 'red', cancelled: 'zinc', }; @@ -55,12 +55,6 @@ export function formatTimestamp(value) { + ':' + pad(date.getMinutes()); } -function formatWordCount(count) { - const n = Number(count) || 0; - - return n > 0 ? n.toLocaleString() : '—'; -} - function subscribeToRecording(recordingId, handler) { if (!window.Echo) { return () => {}; @@ -76,14 +70,6 @@ function subscribeToRecording(recordingId, handler) { }; } -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. */ @@ -208,34 +194,19 @@ export function transcriptionMonitor({ statusUrl, initial }) { } /** - * Index-page Alpine component: patch row status from Reverb events. + * Index-page Alpine component: inline audio player only. + * Status/progress refresh via Livewire Echo + wire:poll. */ -export function recordingsIndex({ recordings, pendingCount }) { - const byId = {}; - - for (const row of recordings) { - byId[row.id] = row; - } - +export function recordingsIndex() { 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) { @@ -282,41 +253,5 @@ export function recordingsIndex({ recordings, pendingCount }) { 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] || {}; - }, }; } diff --git a/resources/views/components/disk-space-bar.blade.php b/resources/views/components/disk-space-bar.blade.php index a1e672e..b8e3be2 100644 --- a/resources/views/components/disk-space-bar.blade.php +++ b/resources/views/components/disk-space-bar.blade.php @@ -2,11 +2,11 @@ /** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */ @endphp
-