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:
@@ -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('');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user