Add cover thumbnail and chapter list drawer to audio mini-bar

- Store cover and chapters array in audioStore (populated by AudioPlayer on startPlayback)
- Page server returns full chapters list on normal path; empty array on preview
- Mini-bar: replace arrow link with book cover thumbnail (fallback book icon)
- Mini-bar track info area is now a button that toggles a chapter list drawer
- Chapter drawer slides up above the mini-bar, lists all chapters with current one highlighted
This commit is contained in:
Admin
2026-03-06 19:46:47 +05:00
parent 765b37aea3
commit 39ad0d6c11
5 changed files with 80 additions and 8 deletions

View File

@@ -44,6 +44,12 @@ class AudioStore {
voice = $state('af_bella'); voice = $state('af_bella');
speed = $state(1.0); 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 ──────────────────────────────────────────── // ── Loading/generation state ────────────────────────────────────────────
status = $state<AudioStatus>('idle'); status = $state<AudioStatus>('idle');
audioUrl = $state(''); audioUrl = $state('');

View File

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

View File

@@ -11,6 +11,9 @@
// Mobile nav drawer state // Mobile nav drawer state
let menuOpen = $state(false); let menuOpen = $state(false);
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
// The single <audio> element that persists across navigations. // The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore. // AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null); let audioEl = $state<HTMLAudioElement | null>(null);
@@ -375,6 +378,45 @@
{#if audioStore.active} {#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl"> <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) --> <!-- Generation progress bar (sits at very top of the bar) -->
{#if audioStore.status === 'generating' || audioStore.status === 'loading'} {#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<div class="h-0.5 bg-zinc-800"> <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"> <div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
<!-- Track info --> <!-- Track info (click to open chapter list drawer) -->
<div class="flex-1 min-w-0"> <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} {#if audioStore.chapterTitle}
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p> <p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
{/if} {/if}
@@ -419,7 +466,7 @@
{:else if audioStore.status === 'loading'} {:else if audioStore.status === 'loading'}
<p class="text-xs text-zinc-500 leading-tight">Loading…</p> <p class="text-xs text-zinc-500 leading-tight">Loading…</p>
{/if} {/if}
</div> </button>
{#if audioStore.status === 'ready'} {#if audioStore.status === 'ready'}
<!-- Skip back 15s --> <!-- Skip back 15s -->
@@ -510,17 +557,28 @@
</svg> </svg>
{/if} {/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} {#if audioStore.slug && audioStore.chapter > 0}
<a <a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}" 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" title="Go to chapter"
aria-label="Go to chapter" aria-label="Go to chapter"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {#if audioStore.cover}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/> <img
</svg> 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> </a>
{/if} {/if}

View File

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

View File

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