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); 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. * Delete the audio file from storage.
*/ */
+5 -3
View File
@@ -44,7 +44,7 @@ return [
| |
*/ */
'component_layout' => 'layouts::app.header', 'component_layout' => 'layouts.app',
/* /*
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
@@ -130,15 +130,17 @@ return [
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) // Pocket-recorder audio can be large; max is kilobytes (2 GiB).
'rules' => ['required', 'file', 'max:2097152'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a', 'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
'ogg', 'oga', 'flac', 'aac', 'webm', 'aiff', 'aif',
], ],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... 'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
], ],
-2
View File
@@ -1,7 +1,5 @@
import './echo'; import './echo';
import { recordingsIndex, transcriptionMonitor } from './transcription'; import { recordingsIndex, transcriptionMonitor } from './transcription';
import { uploadDropzone } from './upload';
window.transcriptionMonitor = transcriptionMonitor; window.transcriptionMonitor = transcriptionMonitor;
window.recordingsIndex = recordingsIndex; window.recordingsIndex = recordingsIndex;
window.uploadDropzone = uploadDropzone;
+72 -12
View File
@@ -2,16 +2,21 @@
* Shared helpers and Alpine components for live transcription updates via Reverb. * Shared helpers and Alpine components for live transcription updates via Reverb.
*/ */
const BADGE_CLASSES = { const BADGE_COLORS = {
done: 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30', done: 'teal',
processing: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', processing: 'amber',
pending: 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30', pending: 'amber',
failed: 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30', failed: 'red',
cancelled: 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30', cancelled: 'zinc',
}; };
export function badgeColorFor(status) {
return BADGE_COLORS[status] || 'zinc';
}
/** @deprecated Use badgeColorFor — kept for any leftover callers */
export function badgeClassFor(status) { export function badgeClassFor(status) {
return BADGE_CLASSES[status] || 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30'; return badgeColorFor(status);
} }
export function formatElapsed(seconds) { export function formatElapsed(seconds) {
@@ -85,13 +90,16 @@ function subscribeToRecordings(recordingIds, handler) {
export function transcriptionMonitor({ statusUrl, initial }) { export function transcriptionMonitor({ statusUrl, initial }) {
return { return {
statusUrl, statusUrl,
status: initial, status: {
...initial,
badge_color: badgeColorFor(initial.status),
},
pollError: null, pollError: null,
tickTimer: null, tickTimer: null,
leaveChannel: null, leaveChannel: null,
get badgeClass() { get badgeColor() {
return badgeClassFor(this.status.status); return badgeColorFor(this.status.status);
}, },
get startButtonLabel() { get startButtonLabel() {
@@ -128,7 +136,11 @@ export function transcriptionMonitor({ statusUrl, initial }) {
applyPayload(payload) { applyPayload(payload) {
const wasActive = this.status.is_active; const wasActive = this.status.is_active;
this.status = { ...this.status, ...payload }; this.status = {
...this.status,
...payload,
badge_color: badgeColorFor(payload.status ?? this.status.status),
};
this.pollError = null; this.pollError = null;
if (this.status.is_active) { if (this.status.is_active) {
@@ -209,6 +221,8 @@ export function recordingsIndex({ recordings, pendingCount }) {
rows: byId, rows: byId,
pendingCount: Number(pendingCount) || 0, pendingCount: Number(pendingCount) || 0,
leaveChannel: null, leaveChannel: null,
playingId: null,
isPlaying: false,
start() { start() {
this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => { this.leaveChannel = subscribeToRecordings(Object.keys(this.rows), (event) => {
@@ -221,6 +235,52 @@ export function recordingsIndex({ recordings, pendingCount }) {
this.leaveChannel(); this.leaveChannel();
this.leaveChannel = null; this.leaveChannel = null;
} }
const player = this.$refs.player;
if (player) {
player.pause();
player.removeAttribute('src');
player.load();
}
},
syncPlayer() {
const player = this.$refs.player;
this.isPlaying = Boolean(player && !player.paused && !player.ended);
if (player?.ended) {
this.playingId = null;
}
},
isPlayingRow(id) {
return this.playingId === id && this.isPlaying;
},
togglePlay(id, url) {
const player = this.$refs.player;
if (!player) {
return;
}
if (this.playingId === id && this.isPlaying) {
player.pause();
return;
}
if (this.playingId !== id) {
player.src = url;
this.playingId = id;
}
player.play().catch(() => {
this.playingId = null;
this.isPlaying = false;
});
}, },
applyPayload(payload) { applyPayload(payload) {
@@ -240,7 +300,7 @@ export function recordingsIndex({ recordings, pendingCount }) {
is_active: payload.is_active, is_active: payload.is_active,
word_count: payload.word_count ?? this.rows[id].word_count, word_count: payload.word_count ?? this.rows[id].word_count,
word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count), word_count_display: formatWordCount(payload.word_count ?? this.rows[id].word_count),
badge_class: badgeClassFor(payload.status), badge_color: badgeColorFor(payload.status),
}; };
this.rows[id] = next; this.rows[id] = next;
-165
View File
@@ -1,165 +0,0 @@
/**
* Upload dropzone: discard duplicate files in the selection (and known server fingerprints)
* before submitting the form.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
export function uploadDropzone({ existingFingerprints = [] } = {}) {
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
const known = new Set(existingFingerprints);
return {
files: [],
dragging: false,
uploading: false,
error: null,
notice: null,
get uploadLabel() {
if (this.uploading) {
return 'Uploading…';
}
if (this.files.length <= 1) {
return 'Upload';
}
return 'Upload ' + this.files.length + ' files';
},
onBrowse(event) {
this.addFiles(Array.from(event.target.files || []));
},
onDrop(event) {
this.dragging = false;
this.addFiles(Array.from(event.dataTransfer?.files || []));
},
addFiles(incoming) {
this.error = null;
this.notice = null;
const accepted = [];
let skippedUnsupported = 0;
let skippedTooLarge = 0;
let skippedDuplicates = 0;
for (const file of incoming) {
if (! this.isAccepted(file)) {
skippedUnsupported++;
continue;
}
if (file.size > MAX_FILE_BYTES) {
skippedTooLarge++;
continue;
}
const fingerprint = this.fingerprint(file);
if (known.has(fingerprint) || this.files.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
if (accepted.some((existing) => this.fingerprint(existing) === fingerprint)) {
skippedDuplicates++;
continue;
}
accepted.push(file);
}
this.files = [...this.files, ...accepted];
if (this.files.length > 50) {
this.error = 'You can upload at most 50 files at once.';
this.files = this.files.slice(0, 50);
}
if (skippedUnsupported > 0) {
this.error = 'Skipped unsupported file type. Use common audio formats only.';
} else if (skippedTooLarge > 0) {
this.error = 'Skipped a file larger than 2 GB.';
}
if (skippedDuplicates > 0) {
this.notice = skippedDuplicates === 1
? 'Skipped 1 duplicate file.'
: `Skipped ${skippedDuplicates} duplicate files.`;
}
this.syncInput();
},
isAccepted(file) {
const name = (file.name || '').toLowerCase();
if (acceptExt.some((ext) => name.endsWith(ext))) {
return true;
}
return (file.type || '').startsWith('audio/');
},
fingerprint(file) {
return `${String(file.name || '').toLowerCase()}:${Number(file.size) || 0}`;
},
fileListKey(file, index = 0) {
return `${this.fingerprint(file)}:${index}`;
},
removeFile(index) {
this.files.splice(index, 1);
this.syncInput();
},
clearFiles() {
this.files = [];
this.notice = null;
this.syncInput();
},
syncInput() {
const input = this.$refs.fileInput;
if (! input) {
return;
}
const transfer = new DataTransfer();
this.files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
},
ensureFilesSelected(event) {
if (this.files.length === 0) {
event.preventDefault();
this.error = 'Drop or choose at least one audio file.';
return;
}
this.uploading = true;
this.error = null;
},
formatSize(bytes) {
if (bytes < 1024) {
return bytes + ' B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
},
};
}
@@ -1,29 +1,24 @@
@php @php
/** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */ /** @var array{used_percent: float, free_percent: float, free_human: string, total_human: string, used_human: string} $disk */
@endphp @endphp
<div class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto max-w-5xl px-4 py-2 sm:px-6">
<div class="flex items-center justify-between gap-3 text-xs text-stone-600 dark:text-zinc-400">
<span class="font-medium text-stone-700 dark:text-zinc-200">Disk space</span>
<span class="tabular-nums">
{{ $disk['free_human'] }} free
<span class="text-stone-400 dark:text-zinc-500">·</span>
{{ $disk['used_percent'] }}% used of {{ $disk['total_human'] }}
</span>
</div>
<div <div
class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700" class="flex items-center gap-2 rounded-lg border border-stone-200 bg-stone-50 px-2.5 py-1.5 dark:border-zinc-600 dark:bg-zinc-900/50"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }} · {{ $disk['used_percent'] }}% used"
>
<div
class="h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-stone-200 dark:bg-zinc-700"
role="progressbar" role="progressbar"
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
aria-valuenow="{{ (int) round($disk['used_percent']) }}" aria-valuenow="{{ (int) round($disk['used_percent']) }}"
aria-label="Disk space used" aria-label="Disk space used"
title="{{ $disk['free_human'] }} free of {{ $disk['total_human'] }}"
> >
<div <div
class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}" class="h-full rounded-full transition-[width] duration-300 {{ $barColor }}"
style="width: {{ min(100, max(0, $disk['used_percent'])) }}%" style="width: {{ min(100, max(0, $disk['used_percent'])) }}%"
></div> ></div>
</div> </div>
</div> <span class="hidden text-xs font-medium tabular-nums text-stone-600 sm:inline dark:text-zinc-300">
{{ $disk['free_human'] }} free
</span>
</div> </div>
@@ -0,0 +1,51 @@
@props([
'status',
'label' => null,
/** @var string|null Alpine expression that returns a row object with badge_color + status_label */
'alpineRow' => null,
])
@php
$label ??= match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$color = match ($status) {
'done' => 'teal',
'processing', 'pending' => 'amber',
'failed' => 'red',
default => 'zinc',
};
@endphp
@if ($alpineRow)
<span {{ $attributes->class('inline-flex') }}>
@foreach (['teal', 'amber', 'red', 'zinc'] as $badgeColor)
@if ($badgeColor === $color)
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@else
<flux:badge
size="sm"
:color="$badgeColor"
x-show="{{ $alpineRow }}.badge_color === '{{ $badgeColor }}'"
x-cloak
x-text="{{ $alpineRow }}.status_label"
>{{ $label }}</flux:badge>
@endif
@endforeach
</span>
@else
<flux:badge size="sm" :color="$color" {{ $attributes }}>
{{ $label }}
</flux:badge>
@endif
+22 -16
View File
@@ -1,17 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head> <head>
@include('partials.head', ['title' => trim($__env->yieldContent('title')) ?: null]) @include('partials.head', ['title' => $title ?? null])
<style>[x-cloak]{display:none!important}</style> <style>[x-cloak]{display:none!important}</style>
</head> </head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100"> <body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800"> <flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6"> <div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" /> <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300"> <a href="{{ route('recordings.index') }}" wire:navigate class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe AndyTranscribe
</a> </a>
@@ -19,12 +17,14 @@
<flux:navbar.item <flux:navbar.item
:href="route('recordings.index')" :href="route('recordings.index')"
:current="request()->routeIs('recordings.index', 'recordings.show')" :current="request()->routeIs('recordings.index', 'recordings.show')"
wire:navigate
> >
{{ __('Recordings') }} {{ __('Recordings') }}
</flux:navbar.item> </flux:navbar.item>
<flux:navbar.item <flux:navbar.item
:href="route('recordings.create')" :href="route('recordings.create')"
:current="request()->routeIs('recordings.create')" :current="request()->routeIs('recordings.create')"
wire:navigate
> >
{{ __('Upload') }} {{ __('Upload') }}
</flux:navbar.item> </flux:navbar.item>
@@ -32,6 +32,8 @@
<flux:spacer /> <flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle /> <x-appearance-toggle />
@auth @auth
@@ -42,17 +44,17 @@
<flux:sidebar collapsible="mobile" sticky class="border-e border-stone-200 bg-white lg:hidden dark:border-zinc-700 dark:bg-zinc-800"> <flux:sidebar collapsible="mobile" sticky class="border-e border-stone-200 bg-white lg:hidden dark:border-zinc-700 dark:bg-zinc-800">
<flux:sidebar.header> <flux:sidebar.header>
<a href="{{ route('recordings.index') }}" class="text-base font-semibold text-teal-800 dark:text-teal-300"> <a href="{{ route('recordings.index') }}" wire:navigate class="text-base font-semibold text-teal-800 dark:text-teal-300">
AndyTranscribe AndyTranscribe
</a> </a>
<flux:sidebar.collapse /> <flux:sidebar.collapse />
</flux:sidebar.header> </flux:sidebar.header>
<flux:sidebar.nav> <flux:sidebar.nav>
<flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')"> <flux:sidebar.item :href="route('recordings.index')" :current="request()->routeIs('recordings.*')" wire:navigate>
{{ __('Recordings') }} {{ __('Recordings') }}
</flux:sidebar.item> </flux:sidebar.item>
<flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')"> <flux:sidebar.item :href="route('recordings.create')" :current="request()->routeIs('recordings.create')" wire:navigate>
{{ __('Upload') }} {{ __('Upload') }}
</flux:sidebar.item> </flux:sidebar.item>
</flux:sidebar.nav> </flux:sidebar.nav>
@@ -60,30 +62,34 @@
<main class="mx-auto max-w-5xl px-4 py-8 sm:px-6"> <main class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
@if (session('success')) @if (session('success'))
<div class="mb-6 rounded border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-900 dark:border-teal-800 dark:bg-teal-950 dark:text-teal-100"> <flux:callout variant="success" icon="check-circle" class="mb-6">
{{ session('success') }} <flux:callout.text>{{ session('success') }}</flux:callout.text>
</div> </flux:callout>
@endif @endif
@if (session('error')) @if (session('error'))
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100"> <flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
{{ session('error') }} <flux:callout.text>{{ session('error') }}</flux:callout.text>
</div> </flux:callout>
@endif @endif
@if ($errors->any()) @if ($errors->any())
<div class="mb-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100"> <flux:callout variant="danger" icon="exclamation-triangle" class="mb-6">
<flux:callout.text>
<ul class="list-disc space-y-1 pl-5"> <ul class="list-disc space-y-1 pl-5">
@foreach ($errors->all() as $error) @foreach ($errors->all() as $error)
<li>{{ $error }}</li> <li>{{ $error }}</li>
@endforeach @endforeach
</ul> </ul>
</div> </flux:callout.text>
</flux:callout>
@endif @endif
@yield('content') {{ $slot }}
</main> </main>
<flux:toast />
@fluxScripts @fluxScripts
</body> </body>
</html> </html>
+1 -2
View File
@@ -5,14 +5,13 @@
<style>[x-cloak]{display:none!important}</style> <style>[x-cloak]{display:none!important}</style>
</head> </head>
<body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100"> <body class="min-h-screen bg-stone-100 text-stone-900 antialiased dark:bg-zinc-900 dark:text-zinc-100">
<x-disk-space-bar />
<flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800"> <flux:header class="border-b border-stone-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
<div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6"> <div class="mx-auto flex w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300"> <a href="{{ route('recordings.index') }}" class="text-lg font-semibold tracking-tight text-teal-800 dark:text-teal-300">
AndyTranscribe AndyTranscribe
</a> </a>
<flux:spacer /> <flux:spacer />
<x-disk-space-bar />
<x-appearance-toggle /> <x-appearance-toggle />
@auth @auth
<x-desktop-user-menu /> <x-desktop-user-menu />
@@ -0,0 +1,11 @@
<div>
<div class="mb-8">
<flux:heading size="xl">Upload recordings</flux:heading>
<flux:text class="mt-1">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</flux:text>
</div>
<livewire:upload-recordings />
</div>
@@ -0,0 +1,160 @@
<div
x-data="recordingsIndex(@js([
'recordings' => $recordings->map(fn ($recording) => [
'id' => $recording->id,
'status' => $recording->transcription_status,
'status_label' => $recording->transcriptionStatusLabel(),
'progress' => $recording->transcription_progress,
'percent' => $recording->transcription_percent,
'is_active' => $recording->isTranscribing(),
'word_count' => $recording->word_count,
'word_count_display' => $recording->word_count > 0
? number_format($recording->word_count)
: '—',
'badge_color' => match ($recording->transcription_status) {
'done' => 'teal',
'processing', 'pending' => 'amber',
'failed' => 'red',
default => 'zinc',
},
])->values(),
'pendingCount' => $pendingCount,
]))"
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 x-show="pendingCount > 0" x-cloak>
<flux:button
type="button"
variant="ghost"
size="sm"
wire:click="queuePending"
>
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</flux:button>
</div>
</div>
</div>
@if ($recordings->isEmpty())
<flux:card class="border-dashed py-16 text-center">
@if (filled($search))
<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>
@else
<flux:text>No recordings yet.</flux:text>
<div class="mt-4">
<flux:link href="{{ route('recordings.create') }}" wire:navigate>Upload your first MP3</flux:link>
</div>
@endif
</flux:card>
@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 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.columns>
<flux:table.rows>
@foreach ($recordings as $recording)
<flux:table.row wire:key="recording-{{ $recording->id }}">
<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="whitespace-normal">
<flux:link href="{{ route('recordings.show', $recording) }}" wire:navigate class="font-medium">
{{ $recording->title }}
</flux:link>
@if ($recording->artist)
<flux:text class="mt-0.5 text-xs">{{ $recording->artist }}</flux:text>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<flux:text class="mt-1 max-w-xl text-xs leading-relaxed">
{{ $snippet }}
</flux:text>
@endif
</flux:table.cell>
<flux:table.cell>{{ $recording->duration_formatted }}</flux:table.cell>
<flux:table.cell>
<span
class="tabular-nums"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</span>
</flux:table.cell>
<flux:table.cell class="whitespace-normal py-2">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
:alpine-row="'row('.$recording->id.')'"
/>
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak
:title="row({{ $recording->id }}).progress"
>
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
<span x-text="row({{ $recording->id }}).progress"></span>
</div>
</flux:table.cell>
<flux:table.cell>
{{ $recording->created_at?->format('Y-m-d H:i') }}
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
@endif
</div>
@@ -0,0 +1,210 @@
<div
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>
<flux:heading size="xl" class="mt-2">{{ $recording->title }}</flux:heading>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-transcription-status-badge
:status="$recording->transcription_status"
:label="$recording->transcriptionStatusLabel()"
alpine-row="status"
/>
<flux:text
class="text-xs"
x-show="status.driver_label"
x-cloak
x-text="status.driver_label"
></flux:text>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<flux:button
type="button"
variant="primary"
icon="play"
@click="$refs.player.paused ? $refs.player.play() : $refs.player.pause()"
>
Play
</flux:button>
<flux:modal.trigger name="delete-recording">
<flux:button type="button" variant="danger">Delete</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-recording" 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 the recording 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">Delete</flux:button>
</div>
</div>
</flux:modal>
</div>
</div>
<flux:card class="mb-6">
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Audio</flux:heading>
<audio
x-ref="player"
class="mt-4 w-full"
controls
preload="metadata"
src="{{ route('recordings.audio', $recording) }}"
>
Your browser does not support audio playback.
</audio>
</flux:card>
<div class="grid gap-6 lg:grid-cols-2">
<flux:card>
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Metadata</flux:heading>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt><flux:text>Original file</flux:text></dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Duration</flux:text></dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Artist</flux:text></dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Album</flux:text></dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Recorded</flux:text></dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Size</flux:text></dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt><flux:text>Uploaded</flux:text></dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</flux:card>
<flux:card>
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcribe</flux:heading>
<div class="mt-4">
<flux:button type="button" variant="primary" wire:click="startTranscription">
<span x-text="startButtonLabel"></span>
</flux:button>
</div>
<div
x-show="status.is_active"
x-cloak
class="mt-3"
>
<flux:button type="button" variant="outline" wire:click="cancelTranscription">
Stop transcription
</flux:button>
</div>
</flux:card>
</div>
<flux:card class="mt-6">
<div class="flex items-center justify-between gap-4">
<flux:heading size="sm" class="uppercase tracking-wide text-zinc-500 dark:text-zinc-400">Transcript</flux:heading>
<flux:button
type="button"
variant="ghost"
size="sm"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
>
Copy
</flux:button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4">
<flux:callout variant="warning" icon="arrow-path">
<flux:callout.heading>
<span x-text="status.progress || 'Working…'"></span>
</flux:callout.heading>
<flux:callout.text>
<span class="tabular-nums" x-text="(status.percent ?? 0) + '%'"></span>
· Elapsed <span x-text="status.elapsed_human || '0s'"></span>
</flux:callout.text>
</flux:callout>
<div class="mt-3">
<flux:progress color="amber" x-bind:value="Math.max(status.percent || 5, 5)" />
</div>
<ul class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-zinc-500">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-600 dark:text-red-400" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4">
<flux:callout icon="stop-circle">
<flux:callout.text>
Transcription stopped. Use the button above to start again.
</flux:callout.text>
</flux:callout>
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4">
<flux:callout variant="danger" icon="exclamation-triangle">
<flux:callout.heading>Transcription failed</flux:callout.heading>
<flux:callout.text>
<span x-text="status.error || 'Check the logs and try again.'"></span>
</flux:callout.text>
</flux:callout>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-zinc-800 dark:text-zinc-100" x-text="status.transcript"></p>
<flux:text
class="mt-4 text-xs"
x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></flux:text>
</div>
<flux:text
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</flux:text>
</flux:card>
</div>
@@ -0,0 +1,67 @@
<div
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
x-data="{ uploading: false, progress: 0 }"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-error="uploading = false"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
<flux:input
wire:model="title"
label="Title (optional)"
description="Used when you upload a single file. Leave blank to use embedded title or filename."
placeholder="Leave blank to use embedded title or filename"
/>
<div>
<flux:label>Audio files</flux:label>
<div
wire:drop.file="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
wire:click="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
role="button"
tabindex="0"
wire:keydown.enter="$upload('audio', { multiple: true, accept: '.mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*' })"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-6 py-10 text-center transition-colors data-dragging:border-accent data-dragging:bg-accent/5 dark:border-white/20 dark:bg-white/5 dark:data-dragging:border-accent dark:data-dragging:bg-accent/10"
>
<div class="mb-3 flex size-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-white/10">
<flux:icon.cloud-arrow-up class="size-6 text-zinc-500 dark:text-zinc-400" />
</div>
<flux:heading size="sm" class="text-zinc-800 dark:text-zinc-100">
Drop audio files here or click to browse
</flux:heading>
<flux:text class="mt-1 max-w-sm text-zinc-500 dark:text-zinc-400">
MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files
</flux:text>
<div
x-show="uploading || $wire.saving"
x-cloak
class="mt-5 w-full max-w-sm space-y-2"
>
<div class="flex items-center justify-between gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span x-text="$wire.saving ? 'Saving recordings…' : 'Uploading…'"></span>
<span x-show="uploading" x-text="progress + '%'"></span>
</div>
<flux:progress color="teal" x-bind:value="uploading ? progress : 100" />
</div>
</div>
@error('audio')
<flux:error>{{ $message }}</flux:error>
@enderror
@error('audio.*')
<flux:error>{{ $message }}</flux:error>
@enderror
</div>
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
Uploads start as soon as you drop or choose files. Duplicate files (same name and size, or identical content) are skipped.
</flux:text>
<div class="flex items-center gap-3">
<flux:link href="{{ route('recordings.index') }}">Cancel</flux:link>
</div>
</div>
-109
View File
@@ -1,109 +0,0 @@
@extends('layouts.app')
@section('title', 'Upload recording')
@section('content')
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight">Upload recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">
Drop one or many pocket-recorder files. Embedded metadata is extracted when available.
Duplicate files (same name and size, or identical content) are skipped.
</p>
</div>
<form
method="POST"
action="{{ route('recordings.store') }}"
enctype="multipart/form-data"
x-data="uploadDropzone(@js(['existingFingerprints' => $existingFingerprints ?? []]))"
@submit="ensureFilesSelected($event)"
class="max-w-2xl space-y-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800"
>
@csrf
<div>
<label class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Audio files</label>
<div
@dragenter.prevent="dragging = true"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop($event)"
@click="$refs.fileInput.click()"
:class="dragging ? 'border-teal-600 bg-teal-50 dark:bg-teal-950/40' : 'border-stone-300 bg-stone-50 hover:border-teal-500 hover:bg-teal-50/40 dark:border-zinc-600 dark:bg-zinc-900/50 dark:hover:border-teal-500 dark:hover:bg-teal-950/30'"
class="mt-2 flex cursor-pointer flex-col items-center justify-center rounded border-2 border-dashed px-6 py-12 text-center transition-colors"
>
<p class="text-sm font-medium text-stone-800 dark:text-zinc-100">Drop audio files here</p>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">or click to browse</p>
<p class="mt-3 text-xs text-stone-500 dark:text-zinc-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p>
</div>
<input
x-ref="fileInput"
id="audio"
type="file"
name="audio[]"
accept=".mp3,.wav,.ogg,.oga,.flac,.m4a,.mp4,.aac,.webm,.wma,.aiff,.aif,audio/*"
multiple
class="sr-only"
@change="onBrowse($event)"
>
</div>
<div x-show="files.length > 0" x-cloak class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-stone-700 dark:text-zinc-200">
<span x-text="files.length"></span>
<span x-text="files.length === 1 ? 'file selected' : 'files selected'"></span>
</p>
<button type="button" @click="clearFiles()" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">
Clear all
</button>
</div>
<ul class="divide-y divide-stone-100 rounded border border-stone-200 dark:divide-zinc-700 dark:border-zinc-700">
<template x-for="(file, index) in files" :key="fileListKey(file, index)">
<li class="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div class="min-w-0">
<p class="truncate font-medium text-stone-800 dark:text-zinc-100" x-text="file.name"></p>
<p class="text-xs text-stone-500 dark:text-zinc-400" x-text="formatSize(file.size)"></p>
</div>
<button
type="button"
@click="removeFile(index)"
class="shrink-0 text-stone-500 hover:text-red-700 dark:text-zinc-400 dark:hover:text-red-400"
>
Remove
</button>
</li>
</template>
</ul>
</div>
<div x-show="files.length === 1" x-cloak>
<label for="title" class="block text-sm font-medium text-stone-700 dark:text-zinc-200">Title (optional)</label>
<input
id="title"
type="text"
name="title"
value="{{ old('title') }}"
placeholder="Leave blank to use embedded title or filename"
class="mt-2 w-full rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100"
>
</div>
<p x-show="notice" x-cloak class="text-sm text-amber-800 dark:text-amber-300" x-text="notice"></p>
<p x-show="error" x-cloak class="text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<div class="flex items-center gap-3">
<button
type="submit"
:disabled="files.length === 0 || uploading"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<span x-text="uploadLabel"></span>
</button>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-600 dark:text-zinc-400 hover:underline dark:text-zinc-400">Cancel</a>
</div>
</form>
@endsection
-146
View File
@@ -1,146 +0,0 @@
@extends('layouts.app')
@section('title', 'Recordings')
@section('content')
@php
$indexRows = $recordings->map(function ($recording) {
return [
'id' => $recording->id,
'status' => $recording->transcription_status,
'status_label' => $recording->transcriptionStatusLabel(),
'progress' => $recording->transcription_progress,
'percent' => $recording->transcription_percent,
'is_active' => $recording->isTranscribing(),
'word_count' => $recording->word_count,
'word_count_display' => $recording->word_count > 0
? number_format($recording->word_count)
: '—',
'badge_class' => match ($recording->transcription_status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
},
];
})->values();
@endphp
<div
x-data="recordingsIndex(@js([
'recordings' => $indexRows,
'pendingCount' => $pendingCount ?? 0,
]))"
x-init="
start();
return () => destroy();
"
>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Recordings</h1>
<p class="mt-1 text-sm text-stone-600 dark:text-zinc-400">Manage pocket-recorder audio and transcripts.</p>
</div>
<div class="flex flex-col gap-2 sm:items-end">
<form method="GET" action="{{ route('recordings.index') }}" class="flex gap-2">
<input
type="search"
name="q"
value="{{ $search ?? '' }}"
placeholder="Search title, artist, transcript…"
class="w-full min-w-[16rem] rounded border border-stone-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-teal-600 focus:outline-none focus:ring-1 focus:ring-teal-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500"
>
<button type="submit" class="rounded border border-stone-300 bg-white px-3 py-2 text-sm hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-800 dark:hover:bg-zinc-700">
Search
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe-pending') }}"
x-show="pendingCount > 0"
x-cloak
>
@csrf
<button type="submit" class="text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Queue <span x-text="pendingCount"></span> pending
<span x-text="pendingCount === 1 ? 'transcription' : 'transcriptions'"></span>
</button>
</form>
</div>
</div>
@if ($recordings->isEmpty())
<div class="rounded border border-dashed border-stone-300 bg-white px-6 py-16 text-center dark:border-zinc-600 dark:bg-zinc-800">
<p class="text-stone-600 dark:text-zinc-400">No recordings yet.</p>
<a href="{{ route('recordings.create') }}" class="mt-4 inline-block text-sm font-medium text-teal-700 hover:underline dark:text-teal-300">
Upload your first MP3
</a>
</div>
@else
<div class="overflow-hidden rounded border border-stone-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<table class="min-w-full divide-y divide-stone-200 text-sm dark:divide-zinc-700">
<thead class="bg-stone-50 text-left text-xs font-medium uppercase tracking-wide text-stone-500 dark:bg-zinc-900/50 dark:text-zinc-400">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Words</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100 dark:divide-zinc-700">
@foreach ($recordings as $recording)
<tr class="hover:bg-stone-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3">
<a href="{{ route('recordings.show', $recording) }}" class="font-medium text-teal-800 hover:underline dark:text-teal-300">
{{ $recording->title }}
</a>
@if ($recording->artist)
<div class="text-xs text-stone-500 dark:text-zinc-400">{{ $recording->artist }}</div>
@endif
@if ($snippet = $recording->transcriptSnippet($search ?: null))
<p class="mt-1 max-w-xl text-xs leading-relaxed text-stone-500 dark:text-zinc-400">
{{ $snippet }}
</p>
@endif
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->duration_formatted }}</td>
<td
class="px-4 py-3 tabular-nums text-stone-600 dark:text-zinc-400"
x-text="row({{ $recording->id }}).word_count_display"
>
{{ $recording->word_count > 0 ? number_format($recording->word_count) : '—' }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="row({{ $recording->id }}).badge_class"
x-text="row({{ $recording->id }}).status_label"
>
{{ $recording->transcriptionStatusLabel() }}
</span>
<div
class="mt-1 max-w-[14rem] truncate text-xs text-amber-700 dark:text-amber-300"
x-show="row({{ $recording->id }}).is_active && row({{ $recording->id }}).progress"
x-cloak
:title="row({{ $recording->id }}).progress"
>
<span x-text="row({{ $recording->id }}).percent ? (row({{ $recording->id }}).percent + '% · ') : ''"></span>
<span x-text="row({{ $recording->id }}).progress"></span>
</div>
</td>
<td class="px-4 py-3 text-stone-600 dark:text-zinc-400">{{ $recording->created_at?->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6">
{{ $recordings->links() }}
</div>
@endif
</div>
@endsection
@@ -1,21 +0,0 @@
@php
$label = $label ?? match ($status) {
'pending' => 'Queued',
'processing' => 'Transcribing',
'done' => 'Done',
'failed' => 'Failed',
'cancelled' => 'Cancelled',
default => (string) $status,
};
$classes = match ($status) {
'done' => 'bg-teal-50 text-teal-800 ring-teal-600/20 dark:bg-teal-950 dark:text-teal-200 dark:ring-teal-400/30',
'processing' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'pending' => 'bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-400/30',
'failed' => 'bg-red-50 text-red-800 ring-red-600/20 dark:bg-red-950 dark:text-red-200 dark:ring-red-400/30',
'cancelled' => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
default => 'bg-stone-100 text-stone-700 ring-stone-500/20 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-500/30',
};
@endphp
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ $classes }}">
{{ $label }}
</span>
-179
View File
@@ -1,179 +0,0 @@
@extends('layouts.app')
@section('title', $recording->title)
@section('content')
<div
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>
<a href="{{ route('recordings.index') }}" class="text-sm text-stone-500 hover:text-stone-800 dark:text-zinc-400 dark:hover:text-zinc-200"> Recordings</a>
<h1 class="mt-2 text-2xl font-semibold tracking-tight">{{ $recording->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm text-stone-600 dark:text-zinc-400">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="badgeClass"
x-text="status.status_label || status.status"
></span>
<template x-if="status.driver_label">
<span class="text-xs text-stone-500 dark:text-zinc-400" x-text="status.driver_label"></span>
</template>
</div>
</div>
<form method="POST" action="{{ route('recordings.destroy', $recording) }}" onsubmit="return confirm('Delete this recording and its file?')">
@csrf
@method('DELETE')
<button type="submit" class="rounded border border-red-200 bg-white px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-900 dark:bg-zinc-800 dark:text-red-300 dark:hover:bg-red-950">
Delete
</button>
</form>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Metadata</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Original file</dt>
<dd class="text-right font-medium">{{ $recording->original_filename }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Duration</dt>
<dd class="font-medium">{{ $recording->duration_formatted }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Artist</dt>
<dd class="font-medium">{{ $recording->artist ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Album</dt>
<dd class="font-medium">{{ $recording->album ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Recorded</dt>
<dd class="font-medium">{{ $recording->recorded_at?->format('Y-m-d') ?: '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Size</dt>
<dd class="font-medium">{{ number_format($recording->file_size_bytes / 1024, 1) }} KB</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-stone-500 dark:text-zinc-400">Uploaded</dt>
<dd class="font-medium">{{ $recording->created_at?->format('Y-m-d H:i') }}</dd>
</div>
</dl>
</section>
<section class="rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcribe</h2>
<form method="POST" action="{{ route('recordings.transcribe', $recording) }}" class="mt-4">
@csrf
<button
type="submit"
class="rounded bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800 dark:bg-teal-600 dark:hover:bg-teal-500"
>
<span x-text="startButtonLabel"></span>
</button>
</form>
<form
method="POST"
action="{{ route('recordings.transcribe.cancel', $recording) }}"
x-show="status.is_active"
x-cloak
class="mt-3"
>
@csrf
<button
type="submit"
class="rounded border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Stop transcription
</button>
</form>
</section>
</div>
<section class="mt-6 rounded border border-stone-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-800">
<div class="flex items-center justify-between gap-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-stone-500 dark:text-zinc-400">Transcript</h2>
<button
type="button"
x-show="!status.is_active && status.has_transcript"
x-cloak
@click="navigator.clipboard.writeText(status.transcript || '')"
class="text-sm text-teal-700 hover:underline dark:text-teal-300"
>
Copy
</button>
</div>
<div x-show="status.is_active" x-cloak class="mt-4 space-y-3 rounded border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/40">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100" x-text="status.progress || 'Working…'"></p>
<p class="tabular-nums text-amber-800 dark:text-amber-200">
<span x-text="(status.percent ?? 0) + '%'"></span>
<span class="mx-1 text-amber-600 dark:text-amber-400">·</span>
<span x-text="'Elapsed ' + (status.elapsed_human || '0s')"></span>
</p>
</div>
<div class="h-2 overflow-hidden rounded-full bg-amber-100 dark:bg-amber-900/50">
<div
class="h-full rounded-full bg-amber-500 transition-all duration-500"
:style="`width: ${Math.max(status.percent || 5, 5)}%`"
></div>
</div>
<ul class="space-y-1 text-xs text-amber-900/80 dark:text-amber-200/80">
<li>
Engine:
<span class="font-medium" x-text="status.driver_label || '—'"></span>
</li>
<template x-if="status.duration_seconds">
<li>
Audio length:
<span class="font-medium" x-text="formatDuration(status.duration_seconds)"></span>
<span class="text-amber-700 dark:text-amber-300">(longer files take longer)</span>
</li>
</template>
<li x-show="pollError" class="text-red-700 dark:text-red-300" x-text="pollError"></li>
</ul>
</div>
<div x-show="!status.is_active && status.status === 'cancelled'" x-cloak class="mt-4 rounded border border-stone-200 bg-stone-50 p-4 text-sm text-stone-700 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-300">
Transcription stopped. Choose an engine above to start again.
</div>
<div x-show="!status.is_active && status.status === 'failed'" x-cloak class="mt-4 space-y-2 rounded border border-red-200 bg-red-50 p-4 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
<p class="font-medium">Transcription failed</p>
<p x-text="status.error || 'Check the logs and try again with another engine.'"></p>
</div>
<div x-show="!status.is_active && status.has_transcript" x-cloak>
<p class="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-stone-800 dark:text-zinc-100" x-text="status.transcript"></p>
<p
class="mt-4 text-xs text-stone-500 dark:text-zinc-400"
x-show="status.transcribed_at"
x-text="status.transcribed_at ? ('Transcribed ' + formatTimestamp(status.transcribed_at)) : ''"
></p>
</div>
<p
x-show="!status.is_active && status.status !== 'failed' && status.status !== 'cancelled' && !status.has_transcript"
x-cloak
class="mt-4 text-sm text-stone-500 dark:text-zinc-400"
>
No transcript yet. Transcription starts automatically after upload, or use the button above.
</p>
</section>
</div>
@endsection
+9 -11
View File
@@ -1,22 +1,20 @@
<?php <?php
use App\Http\Controllers\CancelTranscriptionController; use App\Http\Controllers\StreamRecordingController;
use App\Http\Controllers\RecordingController;
use App\Http\Controllers\TranscribeController;
use App\Http\Controllers\TranscribePendingController;
use App\Http\Controllers\TranscriptionStatusController; use App\Http\Controllers\TranscriptionStatusController;
use App\Livewire\Recordings\Create;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::redirect('/', '/recordings')->name('home'); Route::redirect('/', '/recordings')->name('home');
Route::middleware('auth')->group(function (): void { Route::middleware('auth')->group(function (): void {
Route::resource('recordings', RecordingController::class)->except(['edit', 'update']); Route::get('recordings', Index::class)->name('recordings.index');
Route::post('recordings/transcribe-pending', TranscribePendingController::class) Route::get('recordings/create', Create::class)->name('recordings.create');
->name('recordings.transcribe-pending'); Route::get('recordings/{recording}', Show::class)->name('recordings.show');
Route::post('recordings/{recording}/transcribe', TranscribeController::class) Route::get('recordings/{recording}/audio', StreamRecordingController::class)
->name('recordings.transcribe'); ->name('recordings.audio');
Route::post('recordings/{recording}/transcribe/cancel', CancelTranscriptionController::class)
->name('recordings.transcribe.cancel');
Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class) Route::get('recordings/{recording}/transcription-status', TranscriptionStatusController::class)
->name('recordings.transcription-status'); ->name('recordings.transcription-status');
}); });
+1 -1
View File
@@ -52,7 +52,7 @@ class DiskSpaceTest extends TestCase
$this->get(route('recordings.index')) $this->get(route('recordings.index'))
->assertOk() ->assertOk()
->assertSee('Disk space') ->assertSee('Disk space used', false)
->assertSee('free') ->assertSee('free')
->assertSee('role="progressbar"', false); ->assertSee('role="progressbar"', false);
} }
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class RecordingAudioStreamTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/note.mp3', 'fake-audio-bytes');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertOk()
->assertHeader('content-type', 'audio/mpeg')
->assertHeader('content-disposition', 'inline; filename=note.mp3');
}
public function test_show_page_includes_audio_player(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Playable',
'original_filename' => 'playable.mp3',
'file_path' => 'recordings/playable.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSee('Play', false)
->assertSee(route('recordings.audio', $recording), false)
->assertSee('<audio', false);
}
public function test_index_page_links_to_recording_show(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'List playable',
'original_filename' => 'list.mp3',
'file_path' => 'recordings/list.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSee('List playable')
->assertSee(route('recordings.show', $recording), false);
}
public function test_other_user_cannot_stream_recording_audio(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/secret.mp3', 'fake-audio-bytes');
$owner = User::factory()->create();
$intruder = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $owner->id,
'title' => 'Secret',
'original_filename' => 'secret.mp3',
'file_path' => 'recordings/secret.mp3',
'file_size_bytes' => 16,
'transcription_status' => 'pending',
]);
$this->actingAs($intruder)
->get(route('recordings.audio', $recording))
->assertForbidden();
}
public function test_missing_audio_file_returns_not_found(): void
{
Storage::fake('local');
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Gone',
'original_filename' => 'gone.mp3',
'file_path' => 'recordings/gone.mp3',
'file_size_bytes' => 10,
'transcription_status' => 'pending',
]);
$this->actingAs($user)
->get(route('recordings.audio', $recording))
->assertNotFound();
}
}
+22 -23
View File
@@ -3,12 +3,14 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class RecordingDuplicateUploadTest extends TestCase class RecordingDuplicateUploadTest extends TestCase
@@ -33,19 +35,18 @@ class RecordingDuplicateUploadTest extends TestCase
$first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes'); $first = UploadedFile::fake()->createWithContent('meeting.mp3', 'identical-audio-bytes');
$duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes'); $duplicate = UploadedFile::fake()->createWithContent('meeting-copy.mp3', 'identical-audio-bytes');
$this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$first], ->set('audio', [$first])
])->assertRedirect(); ->assertRedirect();
$this->assertSame(1, Recording::query()->count()); $this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1); Bus::assertDispatched(TranscribeRecording::class, 1);
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$duplicate], ->set('audio', [$duplicate])
]); ->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count()); $this->assertSame(1, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 1); Bus::assertDispatched(TranscribeRecording::class, 1);
} }
@@ -59,18 +60,17 @@ class RecordingDuplicateUploadTest extends TestCase
$two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes'); $two = UploadedFile::fake()->createWithContent('two.mp3', 'same-bytes');
$three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes'); $three = UploadedFile::fake()->createWithContent('three.mp3', 'different-bytes');
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$one, $two, $three], ->set('audio', [$one, $two, $three])
]); ->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertStringContainsString('Skipped 1 duplicate', session('success')); $this->assertStringContainsString('Skipped 1 duplicate', session('success'));
$this->assertSame(2, Recording::query()->count()); $this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2); Bus::assertDispatched(TranscribeRecording::class, 2);
} }
public function test_upload_page_includes_existing_fingerprints_for_client_dedupe(): void public function test_upload_page_mentions_duplicate_skipping(): void
{ {
Recording::query()->create([ Recording::query()->create([
'user_id' => $this->user->id, 'user_id' => $this->user->id,
@@ -84,7 +84,7 @@ class RecordingDuplicateUploadTest extends TestCase
$this->get(route('recordings.create')) $this->get(route('recordings.create'))
->assertOk() ->assertOk()
->assertSee('note.mp3:2048', false) ->assertSeeLivewire('upload-recordings')
->assertSee('Duplicate files', false); ->assertSee('Duplicate files', false);
} }
@@ -103,12 +103,11 @@ class RecordingDuplicateUploadTest extends TestCase
'transcription_status' => 'done', 'transcription_status' => 'done',
]); ]);
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')], ->set('audio', [UploadedFile::fake()->createWithContent('legacy.mp3', 'legacy-audio')])
]); ->assertRedirect(route('recordings.create'))
->assertSessionHas('error');
$response->assertRedirect(route('recordings.create'));
$response->assertSessionHas('error');
$this->assertSame(1, Recording::query()->count()); $this->assertSame(1, Recording::query()->count());
Bus::assertNothingDispatched(); Bus::assertNothingDispatched();
} }
@@ -120,9 +119,9 @@ class RecordingDuplicateUploadTest extends TestCase
$file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash'); $file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash');
$this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$file], ->set('audio', [$file])
])->assertRedirect(); ->assertRedirect();
$recording = Recording::query()->first(); $recording = Recording::query()->first();
$this->assertNotNull($recording); $this->assertNotNull($recording);
+5 -2
View File
@@ -2,9 +2,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Livewire\Recordings\Show;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class RecordingOwnershipTest extends TestCase class RecordingOwnershipTest extends TestCase
@@ -74,8 +76,9 @@ class RecordingOwnershipTest extends TestCase
'transcription_status' => 'done', 'transcription_status' => 'done',
]); ]);
$this->actingAs($intruder) $this->actingAs($intruder);
->delete(route('recordings.destroy', $recording))
Livewire::test(Show::class, ['recording' => $recording])
->assertForbidden(); ->assertForbidden();
$this->assertDatabaseHas('recordings', ['id' => $recording->id]); $this->assertDatabaseHas('recordings', ['id' => $recording->id]);
+36 -45
View File
@@ -4,6 +4,9 @@ namespace Tests\Feature;
use App\Http\Requests\StoreRecordingRequest; use App\Http\Requests\StoreRecordingRequest;
use App\Jobs\TranscribeRecording; use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Livewire\Recordings\Show;
use App\Livewire\UploadRecordings;
use App\Models\Recording; use App\Models\Recording;
use App\Models\User; use App\Models\User;
use App\Services\TranscriptionService; use App\Services\TranscriptionService;
@@ -12,6 +15,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Transcription; use Laravel\Ai\Transcription;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
class RecordingUploadTest extends TestCase class RecordingUploadTest extends TestCase
@@ -54,15 +58,14 @@ class RecordingUploadTest extends TestCase
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg'); $file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [$file], ->set('title', 'Team meeting')
'title' => 'Team meeting', ->set('audio', [$file])
]); ->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first(); $recording = Recording::query()->first();
$this->assertNotNull($recording); $this->assertNotNull($recording);
$response->assertRedirect(route('recordings.show', $recording));
$this->assertSame('Team meeting', $recording->title); $this->assertSame('Team meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status); $this->assertSame('pending', $recording->transcription_status);
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -76,16 +79,13 @@ class RecordingUploadTest extends TestCase
Storage::fake('local'); Storage::fake('local');
Bus::fake(); Bus::fake();
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [ ->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)), UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)), UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)), UploadedFile::fake()->createWithContent('three.ogg', str_repeat('c', 400)),
], ])
]); ->assertRedirect(route('recordings.index'));
$response->assertRedirect(route('recordings.index'));
$response->assertSessionHas('success');
$this->assertSame(3, Recording::query()->count()); $this->assertSame(3, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 3); Bus::assertDispatched(TranscribeRecording::class, 3);
@@ -99,9 +99,9 @@ class RecordingUploadTest extends TestCase
{ {
$this->get(route('recordings.create')) $this->get(route('recordings.create'))
->assertOk() ->assertOk()
->assertSee('Drop audio files here') ->assertSee('Drop audio files here or click to browse')
->assertSee('max 2 GB each', false) ->assertSee('max 2 GB each', false)
->assertSee('name="audio[]"', false); ->assertSeeLivewire('upload-recordings');
} }
public function test_files_larger_than_two_gigabytes_are_rejected(): void public function test_files_larger_than_two_gigabytes_are_rejected(): void
@@ -113,11 +113,9 @@ class RecordingUploadTest extends TestCase
->create('huge.mp3', 10, 'audio/mpeg') ->create('huge.mp3', 10, 'audio/mpeg')
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1); ->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
$this->from(route('recordings.create')) Livewire::test(UploadRecordings::class)
->post(route('recordings.store'), [ ->set('audio', [$file])
'audio' => [$file], ->assertHasErrors(['audio.0']);
])
->assertSessionHasErrors(['audio.0']);
$this->assertSame(0, Recording::query()->count()); $this->assertSame(0, Recording::query()->count());
Bus::assertNothingDispatched(); Bus::assertNothingDispatched();
@@ -133,15 +131,14 @@ class RecordingUploadTest extends TestCase
['clip.ogg', 'audio/ogg', 'ogg-bytes'], ['clip.ogg', 'audio/ogg', 'ogg-bytes'],
['talk.m4a', 'audio/mp4', 'm4a-bytes'], ['talk.m4a', 'audio/mp4', 'm4a-bytes'],
] as [$name, $mime, $contents]) { ] as [$name, $mime, $contents]) {
$response = $this->post(route('recordings.store'), [ Livewire::test(UploadRecordings::class)
'audio' => [UploadedFile::fake()->createWithContent($name, $contents)], ->set('title', $name)
'title' => $name, ->set('audio', [UploadedFile::fake()->createWithContent($name, $contents)])
]); ->assertRedirect();
$recording = Recording::query()->where('title', $name)->first(); $recording = Recording::query()->where('title', $name)->first();
$this->assertNotNull($recording, "Failed uploading {$name}"); $this->assertNotNull($recording, "Failed uploading {$name}");
$response->assertRedirect(route('recordings.show', $recording));
Storage::disk('local')->assertExists($recording->file_path); Storage::disk('local')->assertExists($recording->file_path);
} }
@@ -152,11 +149,9 @@ class RecordingUploadTest extends TestCase
{ {
Storage::fake('local'); Storage::fake('local');
$this->from(route('recordings.create')) Livewire::test(UploadRecordings::class)
->post(route('recordings.store'), [ ->set('audio', [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')])
'audio' => [UploadedFile::fake()->create('notes.txt', 10, 'text/plain')], ->assertHasErrors(['audio.0']);
])
->assertSessionHasErrors(['audio.0']);
} }
public function test_user_can_queue_local_transcription(): void public function test_user_can_queue_local_transcription(): void
@@ -173,8 +168,8 @@ class RecordingUploadTest extends TestCase
// Fake AI so the afterResponse job (sync) does not call a real provider. // Fake AI so the afterResponse job (sync) does not call a real provider.
Transcription::fake(['Queued transcription text.']); Transcription::fake(['Queued transcription text.']);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect(); ->call('startTranscription');
$recording->refresh(); $recording->refresh();
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -332,8 +327,8 @@ class RecordingUploadTest extends TestCase
'updated_at' => now()->subMinutes(10), 'updated_at' => now()->subMinutes(10),
]); ]);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect(); ->call('startTranscription');
$recording->refresh(); $recording->refresh();
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -355,9 +350,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now(), 'transcription_started_at' => now(),
]); ]);
$this->post(route('recordings.transcribe.cancel', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect() ->call('cancelTranscription');
->assertSessionHas('success');
$recording->refresh(); $recording->refresh();
$this->assertSame('cancelled', $recording->transcription_status); $this->assertSame('cancelled', $recording->transcription_status);
@@ -380,9 +374,8 @@ class RecordingUploadTest extends TestCase
'transcription_started_at' => now()->subMinute(), 'transcription_started_at' => now()->subMinute(),
]); ]);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect() ->call('startTranscription');
->assertSessionHas('success');
$recording->refresh(); $recording->refresh();
$this->assertSame('local', $recording->transcription_driver); $this->assertSame('local', $recording->transcription_driver);
@@ -464,8 +457,8 @@ class RecordingUploadTest extends TestCase
'transcribed_at' => now()->subHour(), 'transcribed_at' => now()->subHour(),
]); ]);
$this->post(route('recordings.transcribe', $recording)) Livewire::test(Show::class, ['recording' => $recording])
->assertRedirect(); ->call('startTranscription');
$recording->refresh(); $recording->refresh();
$this->assertSame('Old transcript text.', $recording->transcript); $this->assertSame('Old transcript text.', $recording->transcript);
@@ -497,10 +490,8 @@ class RecordingUploadTest extends TestCase
'transcript' => 'Finished text', 'transcript' => 'Finished text',
]); ]);
$this->from(route('recordings.index')) Livewire::test(Index::class)
->post(route('recordings.transcribe-pending')) ->call('queuePending');
->assertRedirect(route('recordings.index'))
->assertSessionHas('success');
Bus::assertDispatched(TranscribeRecording::class, 1); Bus::assertDispatched(TranscribeRecording::class, 1);
} }
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Recordings;
use App\Jobs\TranscribeRecording;
use App\Livewire\Recordings\Index;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
use Tests\TestCase;
class IndexTest extends TestCase
{
use RefreshDatabase;
public function test_index_page_renders_as_livewire(): void
{
$user = User::factory()->create();
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Pocket note',
'original_filename' => 'note.mp3',
'file_path' => 'recordings/note.mp3',
'file_size_bytes' => 1024,
'transcription_status' => 'done',
'transcript' => 'hello world',
]);
$this->actingAs($user)
->get(route('recordings.index'))
->assertOk()
->assertSeeLivewire(Index::class)
->assertSee('Pocket note');
}
public function test_search_filters_recordings(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Office chat',
'original_filename' => 'office.mp3',
'file_path' => 'recordings/office.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'talking about the pocket recorder today',
]);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Unrelated',
'original_filename' => 'other.mp3',
'file_path' => 'recordings/other.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'nothing useful',
]);
Livewire::test(Index::class)
->set('search', 'pocket recorder')
->assertSee('Office chat')
->assertDontSee('Unrelated');
}
public function test_queue_pending_dispatches_jobs(): void
{
Bus::fake();
$user = User::factory()->create();
$this->actingAs($user);
Recording::query()->create([
'user_id' => $user->id,
'title' => 'Needs work',
'original_filename' => 'needs.mp3',
'file_path' => 'recordings/needs.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'pending',
]);
Livewire::test(Index::class)
->call('queuePending');
Bus::assertDispatched(TranscribeRecording::class, 1);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature\Recordings;
use App\Livewire\Recordings\Show;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class ShowTest extends TestCase
{
use RefreshDatabase;
public function test_show_page_renders_as_livewire(): void
{
$user = User::factory()->create();
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Show me',
'original_filename' => 'show.mp3',
'file_path' => 'recordings/show.mp3',
'file_size_bytes' => 100,
'transcription_status' => 'done',
'transcript' => 'Finished text',
]);
$this->actingAs($user)
->get(route('recordings.show', $recording))
->assertOk()
->assertSeeLivewire(Show::class)
->assertSee('Show me')
->assertSee('Play', false);
}
public function test_user_can_delete_own_recording(): void
{
Storage::fake('local');
Storage::disk('local')->put('recordings/delete-me.mp3', 'bytes');
$user = User::factory()->create();
$this->actingAs($user);
$recording = Recording::query()->create([
'user_id' => $user->id,
'title' => 'Delete me',
'original_filename' => 'delete-me.mp3',
'file_path' => 'recordings/delete-me.mp3',
'file_size_bytes' => 5,
'transcription_status' => 'done',
]);
Livewire::test(Show::class, ['recording' => $recording])
->call('delete')
->assertRedirect(route('recordings.index'));
$this->assertDatabaseMissing('recordings', ['id' => $recording->id]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Jobs\TranscribeRecording;
use App\Livewire\UploadRecordings;
use App\Models\Recording;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class UploadRecordingsLivewireTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->actingAs($this->user);
}
public function test_dropping_files_saves_and_queues_transcription(): void
{
Storage::fake('local');
Bus::fake();
$file = UploadedFile::fake()->create('meeting.mp3', 500, 'audio/mpeg');
Livewire::test(UploadRecordings::class)
->set('title', 'Team meeting')
->set('audio', [$file])
->assertRedirect(route('recordings.show', Recording::query()->first()));
$recording = Recording::query()->first();
$this->assertNotNull($recording);
$this->assertSame('Team meeting', $recording->title);
$this->assertSame('pending', $recording->transcription_status);
Storage::disk('local')->assertExists($recording->file_path);
Bus::assertDispatched(TranscribeRecording::class);
}
public function test_batch_upload_redirects_to_index(): void
{
Storage::fake('local');
Bus::fake();
Livewire::test(UploadRecordings::class)
->set('audio', [
UploadedFile::fake()->createWithContent('one.mp3', str_repeat('a', 400)),
UploadedFile::fake()->createWithContent('two.wav', str_repeat('b', 400)),
])
->assertRedirect(route('recordings.index'));
$this->assertSame(2, Recording::query()->count());
Bus::assertDispatched(TranscribeRecording::class, 2);
}
}