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
-