feat(ui): add audio player component, presign API route, and reading progress tracking
This commit is contained in:
26
ui/src/routes/api/presign/audio/+server.ts
Normal file
26
ui/src/routes/api/presign/audio/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { presignAudio } from '$lib/server/minio';
|
||||
|
||||
/**
|
||||
* GET /api/presign/audio?slug=...&n=...&voice=...&speed=...
|
||||
* Returns a presigned MinIO URL for the audio file so the browser
|
||||
* can stream it directly without going through the server.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
const n = parseInt(url.searchParams.get('n') ?? '', 10);
|
||||
const voice = url.searchParams.get('voice') ?? undefined;
|
||||
const speed = parseFloat(url.searchParams.get('speed') ?? '1') || 1;
|
||||
|
||||
if (!slug || !n || n < 1) {
|
||||
error(400, 'Missing slug or n');
|
||||
}
|
||||
|
||||
try {
|
||||
const presignedUrl = await presignAudio(slug, n, voice, speed);
|
||||
return json({ url: presignedUrl });
|
||||
} catch (e) {
|
||||
error(500, `Could not get presigned URL: ${e}`);
|
||||
}
|
||||
};
|
||||
19
ui/src/routes/api/progress/+server.ts
Normal file
19
ui/src/routes/api/progress/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setProgress } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* POST /api/progress
|
||||
* Body: { slug: string, chapter: number }
|
||||
* Records the user's reading position for the current session.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.slug !== 'string' || typeof body.chapter !== 'number') {
|
||||
error(400, 'Invalid body — expected { slug, chapter }');
|
||||
}
|
||||
|
||||
await setProgress(locals.sessionId, body.slug, body.chapter);
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -1,7 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Record reading progress when the chapter is opened
|
||||
onMount(async () => {
|
||||
try {
|
||||
await fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — silently ignore
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -41,7 +56,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-8">
|
||||
<div class="mb-6">
|
||||
<p class="text-zinc-500 text-sm mb-1">Chapter {data.chapter.number}</p>
|
||||
<h1 class="text-xl font-bold text-zinc-100">
|
||||
{data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
@@ -51,13 +66,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Audio player -->
|
||||
<AudioPlayer slug={data.book.slug} chapter={data.chapter.number} />
|
||||
|
||||
<!-- Chapter content -->
|
||||
{#if !data.html}
|
||||
<div class="text-zinc-500 text-center py-16">
|
||||
<p>Chapter content not available.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose-chapter">
|
||||
<div class="prose-chapter mt-8">
|
||||
{@html data.html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user