feat(player): CF AI preview/swap + fix PB token expiry + local build time
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m44s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m56s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m14s
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m44s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m56s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m14s
- Backend: add GET /api/audio-preview/{slug}/{n} — generates first ~1800-char
chunk via CF AI so playback starts immediately; full chapter cached in MinIO
- Frontend: replace CF AI spinner with preview blob URL + background swap to
full presigned URL when runner finishes, preserving currentTime
- AudioPlayer: isPreview state + 'preview' badge in mini-bar during swap
- pocketbase.ts: fix 403 on stale token — reduce TTL to 50 min + retry once
on 401/403 with forced re-auth (was cached 12 h, PB tokens expire in 1 h)
- Footer build time now rendered in user's local timezone via toLocaleString()
This commit is contained in:
@@ -603,13 +603,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// CF AI (batch-only) or already enqueued by presign: keep the traditional
|
||||
// POST → poll → presign flow. For enqueued, we skip the POST and poll.
|
||||
// CF AI voices: use preview/swap strategy.
|
||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||
// so playback starts immediately — no more waiting behind a spinner.
|
||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||
// swap the <audio> src to the full URL preserving currentTime.
|
||||
audioStore.status = 'generating';
|
||||
audioStore.isPreview = false;
|
||||
startProgress();
|
||||
|
||||
// presignResult.enqueued=true means /api/presign/audio already POSTed on our
|
||||
// behalf — skip the duplicate POST and go straight to polling.
|
||||
// Kick off the full audio generation task in the background
|
||||
// (presignResult.enqueued=true means the presign endpoint already did it).
|
||||
if (!presignResult.enqueued) {
|
||||
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
@@ -618,7 +622,6 @@
|
||||
});
|
||||
|
||||
if (res.status === 402) {
|
||||
// Free daily limit reached — surface upgrade CTA
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
@@ -628,37 +631,96 @@
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
// Already cached — body is { status: 'done' }, no url needed.
|
||||
// Already cached — fast path: presign and play directly.
|
||||
await res.body?.cancel();
|
||||
await finishProgress();
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.isPreview = false;
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
// 202: fall through to polling below.
|
||||
// 202 accepted — fall through: start preview while runner generates
|
||||
}
|
||||
|
||||
// Poll until the runner finishes generating.
|
||||
const final = await pollAudioStatus(slug, chapter, voice);
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
// Fetch the preview clip (first ~1-2 min chunk).
|
||||
// Use an AbortController so we can cancel the background polling if the
|
||||
// user navigates away or stops playback before the full audio is ready.
|
||||
const previewAbort = new AbortController();
|
||||
const qs = new URLSearchParams({ voice });
|
||||
const previewUrl = `/api/audio-preview/${slug}/${chapter}?${qs}`;
|
||||
|
||||
try {
|
||||
const previewRes = await fetch(previewUrl, { signal: previewAbort.signal });
|
||||
if (previewRes.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
if (!previewRes.ok) throw new Error(`Preview failed: HTTP ${previewRes.status}`);
|
||||
|
||||
// The backend responded with the MP3 bytes (or a redirect to MinIO).
|
||||
// Build a blob URL so we can swap it out later without reloading the page.
|
||||
const previewBlob = await previewRes.blob();
|
||||
const previewBlobUrl = URL.createObjectURL(previewBlob);
|
||||
|
||||
audioStore.isPreview = true;
|
||||
audioStore.audioUrl = previewBlobUrl;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore saved time here — preview is always from 0.
|
||||
// Kick off prefetch of next chapter in the background.
|
||||
maybeStartPrefetch();
|
||||
} catch (previewErr: unknown) {
|
||||
if (previewErr instanceof DOMException && previewErr.name === 'AbortError') return;
|
||||
// Preview failed — fall through to the spinner (old behaviour).
|
||||
// We'll wait for the full audio to finish instead.
|
||||
audioStore.isPreview = false;
|
||||
}
|
||||
|
||||
await finishProgress();
|
||||
// Background: poll for full audio; when done, swap src preserving position.
|
||||
try {
|
||||
const final = await pollAudioStatus(slug, chapter, voice, 2000, previewAbort.signal);
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Audio is ready in MinIO — always use a presigned URL for direct playback.
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore time for freshly generated audio — position is 0
|
||||
// Immediately start pre-generating the next chapter in background.
|
||||
maybeStartPrefetch();
|
||||
await finishProgress();
|
||||
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
|
||||
// Swap: save currentTime → update URL → seek to saved position.
|
||||
const savedTime = audioStore.currentTime;
|
||||
const blobUrlToRevoke = audioStore.audioUrl; // capture before overwrite
|
||||
audioStore.isPreview = false;
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
// If we never started a preview (preview fetch failed), switch to ready now.
|
||||
if (audioStore.status !== 'ready') audioStore.status = 'ready';
|
||||
// The layout $effect will load the new src and auto-play from 0.
|
||||
// We seek back to savedTime after a short delay to let the element
|
||||
// attach the new source before accepting a seek.
|
||||
if (savedTime > 0) {
|
||||
setTimeout(() => {
|
||||
audioStore.seekRequest = savedTime;
|
||||
}, 300);
|
||||
}
|
||||
// Revoke the preview blob URL to free memory.
|
||||
// (We need to wait until the new src is playing; 2 s is safe.)
|
||||
setTimeout(() => {
|
||||
if (blobUrlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(blobUrlToRevoke);
|
||||
}
|
||||
}, 2000);
|
||||
maybeStartPrefetch();
|
||||
} catch (pollErr: unknown) {
|
||||
if (pollErr instanceof DOMException && pollErr.name === 'AbortError') return;
|
||||
throw pollErr;
|
||||
}
|
||||
} catch (e) {
|
||||
stopProgress();
|
||||
audioStore.progress = 0;
|
||||
|
||||
Reference in New Issue
Block a user