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');
|
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('');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user