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:
@@ -62,6 +62,13 @@ class AudioStore {
|
||||
/** Pseudo-progress bar value 0–100 during generation */
|
||||
progress = $state(0);
|
||||
|
||||
/**
|
||||
* True while playing a short CF AI preview clip (~1-2 min) and the full
|
||||
* audio is still being generated in the background. Set to false once the
|
||||
* full audio URL has been swapped in.
|
||||
*/
|
||||
isPreview = $state(false);
|
||||
|
||||
// ── Playback state (kept in sync with the <audio> element) ─────────────
|
||||
currentTime = $state(0);
|
||||
duration = $state(0);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -100,8 +100,8 @@ export interface User {
|
||||
let _token = '';
|
||||
let _tokenExp = 0;
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (_token && Date.now() < _tokenExp) return _token;
|
||||
async function getToken(forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh && _token && Date.now() < _tokenExp) return _token;
|
||||
|
||||
log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL });
|
||||
|
||||
@@ -119,7 +119,9 @@ async function getToken(): Promise<string> {
|
||||
|
||||
const data = await res.json();
|
||||
_token = data.token as string;
|
||||
_tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours
|
||||
// PocketBase superuser tokens expire in ~1 hour by default.
|
||||
// Cache for 50 minutes to stay safely within that window.
|
||||
_tokenExp = Date.now() + 50 * 60 * 1000;
|
||||
log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL });
|
||||
return _token;
|
||||
}
|
||||
@@ -132,6 +134,18 @@ async function pbGet<T>(path: string): Promise<T> {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// On 401/403, the token may have expired on the PocketBase side even if
|
||||
// our local TTL hasn't fired yet. Force a refresh and retry once.
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const freshToken = await getToken(true);
|
||||
const retry = await fetch(`${PB_URL}${path}`, {
|
||||
headers: { Authorization: `Bearer ${freshToken}` }
|
||||
});
|
||||
if (retry.ok) return retry.json() as Promise<T>;
|
||||
const retryBody = await retry.text().catch(() => '');
|
||||
log.error('pocketbase', 'GET failed', { path, status: retry.status, body: retryBody });
|
||||
throw new Error(`PocketBase GET ${path} failed: ${retry.status} — ${retryBody}`);
|
||||
}
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'GET failed', { path, status: res.status, body });
|
||||
throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`);
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
@@ -51,6 +55,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
||||
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
||||
}
|
||||
});
|
||||
|
||||
// The single <audio> element that persists across navigations.
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
@@ -750,10 +760,9 @@
|
||||
</div>
|
||||
<!-- Build version / commit SHA / build time -->
|
||||
{#snippet buildTime()}
|
||||
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
|
||||
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
|
||||
{#if buildTimeLocal}
|
||||
<span class="text-(--color-muted)" title="Build time">
|
||||
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
|
||||
· {buildTimeLocal}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -862,8 +871,11 @@
|
||||
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{#if audioStore.isPreview}
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-(--color-brand)/15 text-(--color-brand) leading-none">preview</span>
|
||||
{/if}
|
||||
</p>
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
|
||||
|
||||
Reference in New Issue
Block a user