v2 #1

Open
kamil wants to merge 231 commits from v2 into main
5 changed files with 80 additions and 8 deletions
Showing only changes of commit 39ad0d6c11 - Show all commits

View File

@@ -44,6 +44,12 @@ class AudioStore {
voice = $state('af_bella');
speed = $state(1.0);
/** Cover image URL for the currently loaded book. */
cover = $state('');
/** Full chapter list for the currently loaded book (number + title). */
chapters = $state<{ number: number; title: string }[]>([]);
// ── Loading/generation state ────────────────────────────────────────────
status = $state<AudioStatus>('idle');
audioUrl = $state('');

View File

@@ -59,6 +59,8 @@
cover?: string;
/** Next chapter number, or null/undefined if this is the last chapter. */
nextChapter?: number | null;
/** Full chapter list for the book (number + title). Written into the store. */
chapters?: { number: number; title: string }[];
/** List of available voices from the Kokoro API. */
voices?: string[];
}
@@ -70,6 +72,7 @@
bookTitle = '',
cover = '',
nextChapter = null,
chapters = [],
voices = []
}: Props = $props();
@@ -405,6 +408,8 @@
audioStore.chapter = chapter;
audioStore.chapterTitle = chapterTitle;
audioStore.bookTitle = bookTitle;
audioStore.cover = cover;
audioStore.chapters = chapters;
// Update OS media session (lock screen / notification center).
setMediaSession();

View File

@@ -11,6 +11,9 @@
// Mobile nav drawer state
let menuOpen = $state(false);
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
// The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null);
@@ -375,6 +378,45 @@
{#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl">
<!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-zinc-700 bg-zinc-900 max-h-64 overflow-y-auto">
<div class="max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span>
<button
onclick={() => (chapterDrawerOpen = false)}
class="text-zinc-600 hover:text-zinc-300 transition-colors p-1"
aria-label="Close chapter list"
>
<svg class="w-3.5 h-3.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"/>
</svg>
</button>
</div>
{#each audioStore.chapters as ch (ch.number)}
<a
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter
? 'text-amber-400 font-semibold'
: 'text-zinc-400'}"
>
<span class="tabular-nums text-zinc-600 w-8 shrink-0 text-right">
{ch.number}
</span>
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
{/each}
</div>
</div>
{/if}
<!-- Generation progress bar (sits at very top of the bar) -->
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<div class="h-0.5 bg-zinc-800">
@@ -400,8 +442,13 @@
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
<!-- Track info -->
<div class="flex-1 min-w-0">
<!-- Track info (click to open chapter list drawer) -->
<button
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-zinc-800 transition-colors"
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined}
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined}
>
{#if audioStore.chapterTitle}
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
{/if}
@@ -419,7 +466,7 @@
{:else if audioStore.status === 'loading'}
<p class="text-xs text-zinc-500 leading-tight">Loading…</p>
{/if}
</div>
</button>
{#if audioStore.status === 'ready'}
<!-- Skip back 15s -->
@@ -510,17 +557,28 @@
</svg>
{/if}
<!-- Go to chapter link (only when we know which chapter is playing) -->
<!-- Cover thumbnail / go-to-chapter link -->
{#if audioStore.slug && audioStore.chapter > 0}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}"
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded flex-shrink-0"
class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity"
title="Go to chapter"
aria-label="Go to chapter"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-8 h-11 object-cover rounded"
/>
{:else}
<!-- Fallback book icon -->
<div class="w-8 h-11 flex items-center justify-center bg-zinc-800 rounded border border-zinc-700">
<svg class="w-4 h-4 text-zinc-500" 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-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</div>
{/if}
</a>
{/if}

View File

@@ -77,6 +77,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
voices,
prev: null as number | null,
next: null as number | null,
chapters: [] as { number: number; title: string }[],
sessionId: locals.sessionId,
isPreview: true
};
@@ -129,6 +130,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false
};

View File

@@ -103,6 +103,7 @@
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
/>
{:else}