Compare commits

...

2 Commits

Author SHA1 Message Date
root
48d0ae63bf feat: unified chapter picker overlay + currently reading quick-switch modal on reader
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m40s
Release / Gitea Release (push) Successful in 21s
2026-04-12 17:42:45 +05:00
root
44f81bbf5c surface audio-ready chapters: headphones badge on chapter list, instant-play prompt on reader
All checks were successful
Release / Test backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 6m13s
Release / Gitea Release (push) Successful in 21s
- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice
- GET /api/audio/chapters?slug=&voice= endpoint
- Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count
- Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
2026-04-12 11:13:36 +05:00
9 changed files with 431 additions and 62 deletions

View File

@@ -15,7 +15,7 @@
zIndex?: string;
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
onselect: (chapterNumber: number) => void;
/** Called when the close / chevron-down button is tapped. */
/** Called when the close (✕) button is tapped. */
onclose: () => void;
}
@@ -40,12 +40,6 @@
)
);
/** Scroll the active chapter into view instantly (no animation) when the
* list is first rendered so the user never has to hunt for their position. */
function scrollIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
}
function handleClose() {
search = '';
onclose();
@@ -73,9 +67,9 @@
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<!-- chevron-down -->
<!-- close / ✕ -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
@@ -105,7 +99,6 @@
<button
type="button"
onclick={() => handleSelect(ch.number)}
use:scrollIfActive={ch.number === activeChapter}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { cn } from '$lib/utils';
interface Book {
slug: string;
title: string;
cover?: string;
author?: string;
genres?: string[] | string;
}
interface ReadingEntry {
book: Book;
chapter: number;
}
interface Props {
/** The slug of the book currently being read — highlighted in the list. */
currentSlug: string;
onclose: () => void;
}
let { currentSlug, onclose }: Props = $props();
let entries = $state<ReadingEntry[]>([]);
let loading = $state(true);
let error = $state('');
// ── Fetch in-progress books on mount ──────────────────────────────────────
$effect(() => {
if (!browser) return;
(async () => {
try {
const res = await fetch('/api/home');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as { continue_reading: ReadingEntry[] };
entries = data.continue_reading ?? [];
} catch {
error = 'Failed to load your reading list.';
} finally {
loading = false;
}
})();
});
// ── Body scroll lock ──────────────────────────────────────────────────────
$effect(() => {
if (!browser) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
});
// ── Keyboard: Escape closes ───────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
// ── Navigate to a book's current chapter ─────────────────────────────────
function openBook(entry: ReadingEntry) {
onclose();
goto(`/books/${entry.book.slug}/chapters/${entry.chapter}`);
}
function parseGenres(genres: string[] | string | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try { const p = JSON.parse(genres); return Array.isArray(p) ? p : []; } catch { return []; }
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[70] flex flex-col"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<!-- Panel — matches SearchModal style -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
style="max-height: 100svh;"
onpointerdown={(e) => e.stopPropagation()}
>
<!-- Header row -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<svg class="w-5 h-5 text-(--color-muted) shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="flex-1 text-base font-semibold text-(--color-text)">Currently Reading</span>
<button
type="button"
onclick={onclose}
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close"
>
Cancel
</button>
</div>
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto overscroll-contain">
{#if loading}
<!-- Loading skeleton -->
<div class="px-4 pt-3 pb-4 space-y-1">
{#each [1, 2, 3] as _}
<div class="flex items-center gap-3 px-0 py-3 border-b border-(--color-border)/40">
<div class="shrink-0 w-10 h-14 rounded bg-(--color-surface-3) animate-pulse"></div>
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-(--color-surface-3) rounded animate-pulse w-3/4"></div>
<div class="h-3 bg-(--color-surface-3) rounded animate-pulse w-1/2"></div>
</div>
</div>
{/each}
</div>
{:else if error}
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
{:else if entries.length === 0}
<div class="px-5 py-12 text-center">
<p class="text-sm font-semibold text-(--color-text) mb-1">No books in progress</p>
<p class="text-xs text-(--color-muted)">Books you start reading will appear here.</p>
</div>
{:else}
{#each entries as entry, i}
{@const genres = parseGenres(entry.book.genres)}
{@const isCurrent = entry.book.slug === currentSlug}
<button
type="button"
onclick={() => openBook(entry)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors border-b border-(--color-border)/40 last:border-0',
isCurrent ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Cover thumbnail -->
<div class="shrink-0 w-10 h-14 rounded overflow-hidden bg-(--color-surface-2) border border-(--color-border) relative">
{#if entry.book.cover}
<img src={entry.book.cover} alt="" class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center">
<svg class="w-5 h-5 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2">
<p class={cn(
'text-sm font-semibold leading-snug line-clamp-1 flex-1',
isCurrent ? 'text-(--color-brand)' : 'text-(--color-text)'
)}>
{entry.book.title}
</p>
{#if isCurrent}
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none mt-0.5">
Now
</span>
{/if}
</div>
{#if entry.book.author}
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{entry.book.author}</p>
{/if}
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
<span class="text-xs text-(--color-muted)/60">Ch. {entry.chapter}</span>
{#each genres.slice(0, 2) as g}
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{g}</span>
{/each}
</div>
</div>
<!-- Chevron (dimmed for current, normal for others) -->
<svg class={cn('w-4 h-4 shrink-0', isCurrent ? 'text-(--color-brand)/40' : 'text-(--color-muted)/40')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
{/each}
{/if}
</div>
</div>
</div>

View File

@@ -98,7 +98,6 @@
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
onclose();
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
}
@@ -434,17 +433,6 @@
</div>
{/if}
<!-- Chapter modal (full-screen overlay) -->
{#if showChapterModal && audioStore.chapters.length > 0}
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={audioStore.chapter}
zIndex="z-[80]"
onselect={playChapter}
onclose={() => { showChapterModal = false; }}
/>
{/if}
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
@@ -670,3 +658,16 @@
</div>
</div>
<!-- Chapter picker rendered OUTSIDE the transformed overlay so that
fixed inset-0 anchors to the real viewport, not the CSS-transformed
containing block (transform: translateY breaks fixed positioning). -->
{#if showChapterModal && audioStore.chapters.length > 0}
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={audioStore.chapter}
zIndex="z-[80]"
onselect={playChapter}
onclose={() => { showChapterModal = false; }}
/>
{/if}

View File

@@ -1219,6 +1219,47 @@ export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntr
return entries.slice(0, limit);
}
/**
* Returns a map of chapter number → best available voice for a given slug.
* "Best" means: prefer `preferredVoice` if a done job exists for it,
* otherwise fall back to any done voice for that chapter.
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
*/
export async function getReadyChaptersForSlug(
slug: string,
preferredVoice = ''
): Promise<Map<number, string>> {
const cacheKey = `audio:ready_chapters:${slug}`;
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
const raw = cached ?? await (async () => {
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
chapter: j.chapter,
voice: j.voice ?? ''
}));
await cache.set(cacheKey, result, 60);
return result;
})();
// Build chapter → voices map
const byChapter = new Map<number, string[]>();
for (const { chapter, voice } of raw) {
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
byChapter.get(chapter)!.push(voice);
}
// Resolve best voice per chapter
const result = new Map<number, string>();
for (const [chapter, voices] of byChapter) {
const best = preferredVoice && voices.includes(preferredVoice)
? preferredVoice
: voices[0] ?? '';
result.set(chapter, best);
}
return result;
}
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {

View File

@@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getReadyChaptersForSlug } from '$lib/server/pocketbase';
export const GET: RequestHandler = async ({ url }) => {
const slug = url.searchParams.get('slug') ?? '';
if (!slug) error(400, 'slug is required');
const voice = url.searchParams.get('voice') ?? '';
const readyMap = await getReadyChaptersForSlug(slug, voice);
// Return array of { chapter, voice } pairs
const chapters = [...readyMap.entries()].map(([chapter, v]) => ({ chapter, voice: v }));
return json({ chapters }, {
headers: { 'Cache-Control': 'public, max-age=60' }
});
};

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getProgress, getReadyChaptersForSlug } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ params, locals }) => {
@@ -13,20 +13,26 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (!book) error(404, `Book "${slug}" not found`);
let chapters, progress;
let chapters, progress, readyMap;
try {
[chapters, progress] = await Promise.all([
[chapters, progress, readyMap] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id)
getProgress(locals.sessionId, slug, locals.user?.id),
getReadyChaptersForSlug(slug).catch(() => new Map<number, string>())
]);
} catch (e) {
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
throw error(500, 'Failed to load chapters');
}
// Serialize Map as plain object for SvelteKit data transfer
const readyChapters: Record<number, string> = {};
for (const [ch, voice] of readyMap) readyChapters[ch] = voice;
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
chapters,
lastChapter: progress?.chapter ?? null
lastChapter: progress?.chapter ?? null,
readyChapters
};
};

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { audioStore } from '$lib/audio.svelte';
import type { PageData } from './$types';
import type { ChapterIdx } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
@@ -7,6 +9,18 @@
const PAGE_SIZE = 100;
// ── Ready-to-listen map ──────────────────────────────────────────────────
// readyChapters is a Record<number, string> (chapter → voice) from server
const readySet = $derived(new Set(Object.keys(data.readyChapters).map(Number)));
const readyCount = $derived(readySet.size);
function listenChapter(chapterNum: number) {
const voice = data.readyChapters[chapterNum];
if (voice) audioStore.voice = voice;
audioStore.autoStartChapter = chapterNum;
goto(`/books/${data.book.slug}/chapters/${chapterNum}`);
}
// ── Search ──────────────────────────────────────────────────────────────────
let searchQuery = $state('');
@@ -76,6 +90,20 @@
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
</div>
<!-- ── Audio ready banner ────────────────────────────────────────────────── -->
{#if readyCount > 0}
<div class="flex items-center gap-2.5 mb-4 px-3 py-2.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/25">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
</svg>
<p class="text-sm text-(--color-brand) font-medium">
{readyCount} chapter{readyCount !== 1 ? 's' : ''} ready to listen — tap
<svg class="w-3 h-3 inline-block" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
to play instantly
</p>
</div>
{/if}
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
<div class="relative mb-4">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted) pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -147,40 +175,65 @@
<div class="flex flex-col gap-0.5">
{#each visibleChapters as chapter}
{@const isCurrent = data.lastChapter === chapter.number}
<a
href="/books/{data.book.slug}/chapters/{chapter.number}"
id="ch-{chapter.number}"
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
{@const isReady = readySet.has(chapter.number)}
<div
class="flex items-center gap-1 rounded transition-colors group
{isCurrent ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)/60'}"
>
<!-- Number badge -->
<span
class="w-9 text-right text-sm font-mono flex-shrink-0
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
<a
href="/books/{data.book.slug}/chapters/{chapter.number}"
id="ch-{chapter.number}"
class="flex items-center gap-3 px-3 py-2.5 flex-1 min-w-0"
>
{chapter.number}
</span>
<!-- Title -->
<span
class="flex-1 min-w-0 text-sm truncate transition-colors
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
>
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
</span>
<!-- Date — desktop only -->
{#if chapter.date_label}
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
{chapter.date_label}
<!-- Number badge -->
<span
class="w-9 text-right text-sm font-mono flex-shrink-0
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
>
{chapter.number}
</span>
{/if}
<!-- Reading indicator -->
{#if isCurrent}
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
<!-- Title -->
<span
class="flex-1 min-w-0 text-sm truncate transition-colors
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
>
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
</span>
<!-- Headphones icon for ready chapters -->
{#if isReady}
<svg class="w-3.5 h-3.5 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label="Audio ready">
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
</svg>
{/if}
<!-- Date — desktop only -->
{#if chapter.date_label}
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
{chapter.date_label}
</span>
{/if}
<!-- Reading indicator -->
{#if isCurrent}
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
{/if}
</a>
<!-- Instant-play button (only for ready chapters) -->
{#if isReady}
<button
type="button"
onclick={() => listenChapter(chapter.number)}
class="mr-2 flex items-center justify-center w-7 h-7 rounded-full bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) transition-colors shrink-0"
title="Listen now"
aria-label="Listen to chapter {chapter.number} now"
>
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
{/if}
</a>
</div>
{/each}
</div>

View File

@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getReadyChaptersForSlug } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import type { Voice } from '$lib/types';
@@ -87,18 +87,22 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
lang: '',
translationStatus: 'unavailable' as string,
isPro: locals.isPro,
chapterImageUrl: null as string | null
chapterImageUrl: null as string | null,
audioReady: false,
availableVoice: null as string | null
};
}
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
// Fetch book metadata, chapter index, voice list, and chapter image check in parallel.
// Fetch book metadata, chapter index, voice list, chapter image check, and
// audio-ready map in parallel.
// HEAD /api/chapter-image checks existence cheaply without downloading the image.
const [book, chapters, voicesRes, chapterImageRes] = await Promise.all([
const [book, chapters, voicesRes, chapterImageRes, readyMap] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
backendFetch('/api/voices').catch(() => null),
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null)
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null),
getReadyChaptersForSlug(slug).catch(() => new Map<number, string>())
]);
if (!book) error(404, `Book "${slug}" not found`);
@@ -112,6 +116,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
? `/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`
: null;
// Audio readiness for this specific chapter
const availableVoice = readyMap.get(n) ?? null;
const audioReady = availableVoice !== null;
// Parse voices — fall back to empty list on error
let voices: Voice[] = [];
try {
@@ -146,7 +154,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
lang,
translationStatus: 'done',
isPro: locals.isPro,
chapterImageUrl
chapterImageUrl,
audioReady,
availableVoice
};
}
// 404 = not generated yet — fall through to original, UI can trigger generation
@@ -204,6 +214,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
lang: useTranslation ? lang : '',
translationStatus,
isPro: locals.isPro,
chapterImageUrl
chapterImageUrl,
audioReady,
availableVoice
};
};

View File

@@ -5,6 +5,7 @@
import { page } from '$app/state';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import CurrentlyReadingModal from '$lib/components/CurrentlyReadingModal.svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
import { audioStore } from '$lib/audio.svelte';
@@ -22,6 +23,9 @@
let settingsPanelOpen = $state(false);
let settingsTab = $state<'reading' | 'listening'>('reading');
// ── Currently reading modal ───────────────────────────────────────────────
let readingModalOpen = $state(false);
const READER_FONTS = [
{ id: 'system', label: 'System' },
{ id: 'serif', label: 'Serif' },
@@ -427,6 +431,13 @@
audioExpanded = true;
}
});
// ── Audio-ready instant play ─────────────────────────────────────────────────
function listenNow() {
if (data.availableVoice) audioStore.voice = data.availableVoice;
audioStore.autoStartChapter = data.chapter.number;
audioExpanded = true;
}
</script>
<svelte:head>
@@ -510,6 +521,18 @@
<!-- Chapter heading + meta + language switcher -->
<div class="mb-6">
<!-- Book title — tappable, opens Currently Reading modal -->
<button
type="button"
onclick={() => (readingModalOpen = true)}
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-brand) text-sm font-medium transition-colors mb-1.5 max-w-full"
title="Switch book"
>
<span class="truncate">{data.book.title}</span>
<svg class="w-3.5 h-3.5 shrink-0 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-1.5">
{m.reader_chapter_n({ n: String(data.chapter.number) })}
</p>
@@ -600,6 +623,27 @@
</a>
</div>
{:else}
<!-- Audio ready prompt — shown when audio exists and player is not yet active -->
{#if data.audioReady && !(audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active)}
<div class="mb-3 flex items-center gap-3 px-4 py-3 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30">
<svg class="w-5 h-5 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/>
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-(--color-text)">Audio ready</p>
<p class="text-xs text-(--color-muted)">This chapter has been narrated — listen instantly</p>
</div>
<button
type="button"
onclick={listenNow}
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Listen now
</button>
</div>
{/if}
<!-- Collapsible audio panel -->
<div class="mb-6">
<button
@@ -1287,3 +1331,11 @@
</div>
{/if}
{/if}
<!-- Currently Reading modal -->
{#if readingModalOpen}
<CurrentlyReadingModal
currentSlug={data.book.slug}
onclose={() => (readingModalOpen = false)}
/>
{/if}