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

- 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:
Admin
2026-04-05 22:12:22 +05:00
parent a308672317
commit 1abb4cd714
6 changed files with 236 additions and 29 deletions

View File

@@ -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;