Compare commits

...

12 Commits

Author SHA1 Message Date
root
015cb8a0cd add Ready to Listen feature: audio book shelf on home + /listen browse page
Some checks failed
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Failing after 45s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- getBooksWithAudioCount() in pocketbase.ts aggregates done audio_jobs, deduplicates by chapter per slug, caches 5 min
- GET /api/audio/books endpoint
- home page: readyToListen shelf with headphones badge, chapter count, Listen button, hideable
- /listen page: full grid with search, sort (most narrated / A-Z / recent), empty state
2026-04-12 10:18:40 +05:00
root
53edb6fdef fix: seek bars work on iOS (onchange+oninput), minimal bar is range input, float drag direction corrected
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m43s
Release / Docker (push) Successful in 6m0s
Release / Gitea Release (push) Successful in 20s
2026-04-12 08:28:59 +05:00
root
f79538f6b2 fix: use untrack() in float clamp effect to prevent reactive loop that locked up the page
All checks were successful
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m18s
Release / Gitea Release (push) Successful in 21s
2026-04-12 07:49:08 +05:00
root
a3a218fef1 fix: float circle releases pointer capture on pointerup/cancel so page stays responsive
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m53s
Release / Docker (push) Successful in 6m28s
Release / Gitea Release (push) Successful in 21s
2026-04-11 23:56:14 +05:00
root
0c6c3b8c43 feat: show search button on chapter reader pages
All checks were successful
Release / Test backend (push) Successful in 1m3s
Release / Check ui (push) Successful in 1m51s
Release / Docker (push) Successful in 6m16s
Release / Gitea Release (push) Successful in 25s
2026-04-11 23:37:12 +05:00
root
a47cc0e711 feat: float player is now a draggable circle with viewport clamping and tap-to-play/pause
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 2m15s
Release / Docker (push) Successful in 6m36s
Release / Gitea Release (push) Successful in 39s
2026-04-11 23:35:49 +05:00
root
ac3d6e1784 fix: move hamburger backdrop outside <header> so drawer items are not blurred
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m21s
Release / Gitea Release (push) Successful in 28s
2026-04-11 23:22:32 +05:00
root
adacd8944b fix: AudioPlayer chapter picker highlights audioStore.chapter (playing) not page chapter prop
All checks were successful
Release / Test backend (push) Successful in 59s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 6m24s
Release / Gitea Release (push) Successful in 28s
2026-04-11 23:13:24 +05:00
root
ea58dab71c fix: hamburger backdrop starts below header so menu items are not blurred
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 1m58s
Release / Docker (push) Successful in 6m31s
Release / Gitea Release (push) Successful in 37s
2026-04-11 18:39:32 +05:00
root
cf3a3ad910 feat: add backdrop blur overlay when mobile hamburger menu is open
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m46s
Release / Docker (push) Successful in 6m5s
Release / Gitea Release (push) Successful in 34s
2026-04-11 17:30:55 +05:00
root
8660c675b6 fix: suppress mini-bar for float/minimal player styles; persist float position
All checks were successful
Release / Test backend (push) Successful in 1m0s
Release / Check ui (push) Successful in 1m42s
Release / Docker (push) Successful in 5m55s
Release / Gitea Release (push) Successful in 39s
2026-04-11 17:20:09 +05:00
root
1f4d67dc77 fix: player float mode now works; add minimal player style
All checks were successful
Release / Test backend (push) Successful in 52s
Release / Check ui (push) Successful in 1m49s
Release / Docker (push) Successful in 6m29s
Release / Gitea Release (push) Successful in 35s
Float mode was broken because AudioPlayer was unmounted the moment
audioStore.active became true — exactly when the float overlay needs
to render. Fix: keep AudioPlayer mounted in float and minimal modes
regardless of audioStore.active; only standard mode shows the
'Controls below' message.

Adds a third 'minimal' style: a compact single-row bar (skip ±,
play/pause, seek, time) with no voice picker or chapter browser.
Voice picker and chapter button are hidden in the idle pill too.

Settings UI updated to show all three options with a live
description of what each style does.
2026-04-11 16:00:46 +05:00
10 changed files with 687 additions and 157 deletions

View File

@@ -170,6 +170,21 @@ class AudioStore {
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
}
/**
* When true the persistent mini-bar in +layout.svelte is hidden.
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
* so the in-page player is the sole control surface.
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
*/
suppressMiniBar = $state(false);
/**
* Position of the draggable float overlay (bottom-right anchor offsets).
* Stored here (module singleton) so the position survives chapter navigation.
* x > 0 = moved left; y > 0 = moved up.
*/
floatPos = $state({ x: 0, y: 0 });
/** True when the currently loaded track matches slug+chapter */
isCurrentChapter(slug: string, chapter: number): boolean {
return this.slug === slug && this.chapter === chapter;

View File

@@ -50,6 +50,7 @@
import { audioStore } from '$lib/audio.svelte';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
@@ -71,8 +72,11 @@
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
playerStyle?: 'standard' | 'float';
/** Visual style of the player card.
* 'standard' = full inline card with voice/chapter controls;
* 'minimal' = compact single-row bar (play + seek + time only);
* 'float' = draggable overlay anchored bottom-right. */
playerStyle?: 'standard' | 'minimal' | 'float';
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
wordCount?: number;
}
@@ -942,24 +946,86 @@
}
// ── Float player drag state ──────────────────────────────────────────────
/** Position of the floating overlay (bottom-right anchor by default). */
let floatPos = $state({ x: 0, y: 0 });
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
//
// To keep the circle in the viewport we clamp so that the element never goes
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
const FLOAT_SIZE = 56; // px — must match w-14
const FLOAT_MARGIN = 16; // px — 1rem
function clampFloatPos(x: number, y: number): { x: number; y: number } {
const vw = typeof window !== 'undefined' ? window.innerWidth : 400;
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
const maxX = FLOAT_MARGIN;
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw;
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh;
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
const maxY = FLOAT_MARGIN;
return {
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
};
}
let floatDragging = $state(false);
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
// Track total pointer movement to distinguish tap vs drag
let floatMoved = $state(false);
function onFloatPointerDown(e: PointerEvent) {
e.stopPropagation();
floatDragging = true;
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
floatMoved = false;
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onFloatPointerMove(e: PointerEvent) {
if (!floatDragging) return;
floatPos = {
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
const dx = e.clientX - floatDragStart.mx;
const dy = e.clientY - floatDragStart.my;
// Only start moving if dragged > 6px to preserve tap detection
if (!floatMoved && Math.hypot(dx, dy) < 6) return;
floatMoved = true;
// right = MARGIN - x → drag right (dx>0) should decrease right → x increases → x = ox + dx
// bottom = MARGIN - y → drag down (dy>0) should decrease bottom → y increases → y = oy + dy
const raw = {
x: floatDragStart.ox + dx,
y: floatDragStart.oy + dy,
};
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
}
function onFloatPointerUp() { floatDragging = false; }
function onFloatPointerUp(e: PointerEvent) {
if (!floatDragging) return;
if (floatDragging && !floatMoved) {
// Tap: toggle play/pause
audioStore.toggleRequest++;
}
floatDragging = false;
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ }
}
// Clamp saved position to viewport on mount and on resize.
// Use untrack() when reading floatPos to avoid a reactive loop
// (reading + writing the same state inside $effect would re-trigger forever).
$effect(() => {
if (typeof window === 'undefined') return;
const clamp = () => {
const { x, y } = untrack(() => audioStore.floatPos);
audioStore.floatPos = clampFloatPos(x, y);
};
clamp();
window.addEventListener('resize', clamp);
return () => window.removeEventListener('resize', clamp);
});
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -1034,7 +1100,8 @@
</svg>
</button>
<!-- Track info -->
<!-- Track info (hidden in minimal style) -->
{#if playerStyle !== 'minimal'}
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
{m.reader_play_narration()}
@@ -1091,9 +1158,10 @@
{/if}
</div>
</div>
{/if}
<!-- Chapters button (right side) -->
{#if chapters.length > 0}
<!-- Chapters button (right side, hidden in minimal style) -->
{#if chapters.length > 0 && playerStyle !== 'minimal'}
<button
type="button"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
@@ -1167,6 +1235,67 @@
{:else}
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
<div class="px-3 py-2.5 flex items-center gap-2">
<!-- Skip back 15s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
title="-15s"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
<!-- Play/pause -->
<button
type="button"
onclick={() => { audioStore.toggleRequest++; }}
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
>
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
title="+30s"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
</svg>
</button>
<!-- Seek bar — proper range input so drag works on iOS too -->
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
onchange={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
class="flex-1 h-1.5 cursor-pointer"
style="accent-color: var(--color-brand);"
/>
<!-- Time -->
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
</span>
</div>
{:else}
<div class="p-4">
<div class="flex items-center justify-end gap-2 mb-3">
<!-- Chapter picker button -->
@@ -1372,6 +1501,7 @@
</div>
{/if}
{/if}
{/if}
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that
@@ -1380,7 +1510,7 @@
{#if showChapterPanel && audioStore.chapters.length > 0}
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={chapter}
activeChapter={audioStore.chapter}
zIndex="z-[60]"
onselect={playChapter}
onclose={() => { showChapterPanel = false; }}
@@ -1388,104 +1518,86 @@
{/if}
<!-- ── Float player overlay ──────────────────────────────────────────────────
Rendered outside all containers so fixed positioning is never clipped.
A draggable circle anchored to the viewport.
Tap = toggle play/pause.
Drag = reposition (clamped to viewport).
Visible when playerStyle='float' and audio is active for this chapter. -->
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed z-[55] select-none"
style="
bottom: calc(4.5rem + {-floatPos.y}px);
right: calc(1rem + {-floatPos.x}px);
bottom: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
touch-action: none;
width: {FLOAT_SIZE}px;
height: {FLOAT_SIZE}px;
"
onpointerdown={onFloatPointerDown}
onpointermove={onFloatPointerMove}
onpointerup={onFloatPointerUp}
onpointercancel={(e) => { floatDragging = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }}
>
<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
<!-- Drag handle + title row -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
onpointerdown={onFloatPointerDown}
onpointermove={onFloatPointerMove}
onpointerup={onFloatPointerUp}
onpointercancel={onFloatPointerUp}
>
<!-- Drag grip dots -->
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
<!-- Pulsing ring when playing -->
{#if audioStore.isPlaying}
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
{/if}
<!-- Circle button -->
<div
class="absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center {floatDragging ? 'cursor-grabbing' : 'cursor-grab'} transition-transform active:scale-95"
>
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<!-- Spinner -->
<svg class="w-6 h-6 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
</span>
<!-- Status dot -->
{#if audioStore.isPlaying}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
{/if}
</div>
<!-- Seek bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
class="mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
onclick={seekFromBar}
>
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
</div>
<!-- Controls row -->
<div class="flex items-center gap-1 px-3 pb-2.5">
<!-- Skip back 15s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
title="-15s"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
<!-- Play/pause -->
<button
type="button"
onclick={() => { audioStore.toggleRequest++; }}
class="w-9 h-9 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0"
>
{#if audioStore.isPlaying}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
title="+30s"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
</svg>
</button>
<!-- Time -->
<span class="flex-1 text-[11px] text-center tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)}
<span class="opacity-50">/</span>
{formatDuration(audioStore.duration)}
</span>
<!-- Speed -->
<span class="text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0">
{audioStore.speed}×
</span>
</div>
{:else if audioStore.isPlaying}
<!-- Pause icon -->
<svg class="w-6 h-6 text-white pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<!-- Play icon -->
<svg class="w-6 h-6 text-white ml-0.5 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</div>
<!-- Progress arc ring (thin, overlaid on circle edge) -->
{#if audioStore.duration > 0}
{@const r = 26}
{@const circ = 2 * Math.PI * r}
{@const dash = (audioStore.currentTime / audioStore.duration) * circ}
<svg
class="absolute inset-0 pointer-events-none -rotate-90"
width={FLOAT_SIZE}
height={FLOAT_SIZE}
viewBox="0 0 {FLOAT_SIZE} {FLOAT_SIZE}"
>
<circle
cx={FLOAT_SIZE / 2}
cy={FLOAT_SIZE / 2}
r={r}
fill="none"
stroke="rgba(255,255,255,0.25)"
stroke-width="2.5"
/>
<circle
cx={FLOAT_SIZE / 2}
cy={FLOAT_SIZE / 2}
r={r}
fill="none"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-dasharray="{circ}"
stroke-dashoffset="{circ - dash}"
style="transition: stroke-dashoffset 0.5s linear;"
/>
</svg>
{/if}
</div>
{/if}

View File

@@ -1165,6 +1165,60 @@ export async function getSlugsWithAudio(): Promise<Set<string>> {
return new Set(jobs.map((j) => j.slug));
}
/**
* Returns books that have at least one completed audio chapter, sorted by
* number of narrated chapters descending.
* Cached for 5 minutes (same TTL as the catalogue audio badge).
*/
const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count';
const AUDIO_BOOKS_CACHE_TTL = 5 * 60;
export interface AudioBookEntry {
book: Book;
audioChapters: number;
}
export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntry[]> {
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
if (cached) return cached.slice(0, limit);
// Count done jobs per slug
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
const countBySlug = new Map<string, number>();
for (const j of jobs) {
// audio_jobs can have multiple voice variants for the same chapter — deduplicate
// by chapter number so we count chapters, not voice variants.
// cache_key format: "slug/chapter/voice"
const slug = j.slug;
if (!countBySlug.has(slug)) countBySlug.set(slug, 0);
// We'll use a Set per slug after this loop instead
}
// Build slug → Set<chapter> to deduplicate voice variants
const chapsBySlug = new Map<string, Set<number>>();
for (const j of jobs) {
if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set());
chapsBySlug.get(j.slug)!.add(j.chapter);
}
const slugs = [...chapsBySlug.keys()];
if (slugs.length === 0) return [];
const books = await getBooksBySlugs(slugs);
const bookMap = new Map(books.map((b) => [b.slug, b]));
const entries: AudioBookEntry[] = [];
for (const [slug, chapters] of chapsBySlug) {
const book = bookMap.get(slug);
if (!book) continue;
entries.push({ book, audioChapters: chapters.size });
}
// Sort by most chapters narrated first
entries.sort((a, b) => b.audioChapters - a.audioChapters);
await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL);
return entries.slice(0, limit);
}
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {

View File

@@ -512,7 +512,7 @@
style="display:none"
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
@@ -570,20 +570,18 @@
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Universal search button (hidden on chapter/reader pages) -->
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
title="Search (/ or ⌘K)"
aria-label="Search books"
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
>
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
{/if}
<!-- Universal search button -->
<button
type="button"
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
title="Search (/ or ⌘K)"
aria-label="Search books"
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
>
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Notifications bell -->
{#if data.user}
@@ -745,14 +743,15 @@
</Button>
</div>
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
{:else}
<div class="ml-auto">
<a
@@ -885,6 +884,17 @@
{/if}
</header>
<!-- Backdrop for mobile hamburger menu — outside <header> so the blur
only affects page content below, not the drawer items themselves -->
{#if menuOpen}
<div
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
onpointerdown={() => { menuOpen = false; }}
aria-hidden="true"
></div>
{/if}
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{#key page.url.pathname + page.url.search}
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
@@ -959,7 +969,7 @@
</div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active}
{#if audioStore.active && !audioStore.suppressMiniBar}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
<!-- Generation progress bar (sits at very top of the bar) -->
@@ -980,6 +990,7 @@
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
onchange={seek}
class="w-full h-1 accent-[--color-brand] cursor-pointer block"
style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
/>
@@ -1156,8 +1167,6 @@
// Don't intercept when typing in an input/textarea
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
// Don't open on chapter reader pages
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
if (searchOpen) return;
// `/` key or Cmd/Ctrl+K
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {

View File

@@ -6,7 +6,8 @@ import {
getHomeStats,
getSubscriptionFeed,
getTrendingBooks,
getRecommendedBooks
getRecommendedBooks,
getBooksWithAudioCount
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
@@ -87,8 +88,8 @@ export const load: PageServerLoad = async ({ locals }) => {
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Fetch trending, recommendations, and subscription feed in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
// Fetch trending, recommendations, subscription feed, and audio books in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([
getTrendingBooks(8).catch(() => [] as Book[]),
topGenres.length > 0
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
@@ -98,12 +99,18 @@ export const load: PageServerLoad = async ({ locals }) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: Promise.resolve([])
: Promise.resolve([]),
getBooksWithAudioCount(20).catch(() => [])
]);
// Strip books the user is already reading from trending (redundant)
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
// Strip already-reading books from audio shelf; cap at 8
const readyToListen = audioBooks
.filter((e) => !inProgressSlugs.has(e.book.slug))
.slice(0, 8);
return {
continueInProgress,
continueCompleted,
@@ -111,6 +118,7 @@ export const load: PageServerLoad = async ({ locals }) => {
subscriptionFeed,
trendingBooks: trendingFiltered,
recommendedBooks,
readyToListen,
topGenre: topGenres[0] ?? null,
stats: {
...stats,

View File

@@ -8,7 +8,7 @@
let { data }: { data: PageData } = $props();
// ── Section visibility ────────────────────────────────────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
const SECTIONS_KEY = 'home_sections_v1';
function loadHidden(): Set<SectionId> {
@@ -40,6 +40,7 @@
'from-following': 'From Following',
'trending': 'Trending Now',
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
'ready-to-listen': 'Ready to Listen',
});
const hiddenList = $derived(
@@ -307,6 +308,69 @@
</section>
{/if}
<!-- ── Ready to Listen shelf ──────────────────────────────────────────────────── -->
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Ready to Listen</h2>
<div class="flex items-center gap-3">
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.readyToListen as { book, audioChapters }}
{@const genres = parseGenres(book.genres)}
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<a href="/books/{book.slug}" class="block">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
</div>
{/if}
<!-- Headphones badge -->
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
<svg class="w-3 h-3" 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>
{audioChapters} ch
</span>
</div>
</a>
<div class="p-2 flex flex-col gap-1 flex-1">
<a href="/books/{book.slug}" class="block">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</a>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
<!-- Listen Ch.1 button -->
<button
type="button"
onclick={() => playChapter(book.slug, 1)}
class="mx-2 mb-2 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
aria-label="Listen from chapter 1"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Listen
</button>
</div>
{/each}
</div>
</section>
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
{#if !hidden.has('browse-genre')}
<section class="mb-10">

View File

@@ -0,0 +1,17 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
/**
* GET /api/audio/books
* Returns books that have at least one completed narrated chapter,
* sorted by number of narrated chapters descending.
* Cached 5 minutes at the CDN/proxy level.
*/
export const GET: RequestHandler = async () => {
const entries = await getBooksWithAudioCount(100).catch(() => []);
return json(
{ books: entries },
{ headers: { 'Cache-Control': 'public, max-age=300' } }
);
};

View File

@@ -52,7 +52,7 @@
type LineSpacing = 'compact' | 'normal' | 'relaxed';
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
type PlayerStyle = 'standard' | 'float';
type PlayerStyle = 'standard' | 'minimal' | 'float';
/** Controls how many lines fit on a page by adjusting the container height offset. */
type PageLines = 'less' | 'normal' | 'more';
@@ -100,6 +100,33 @@
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
});
// ── Suppress mini-bar for float / minimal player styles ──────────────────────
// The in-page player is the sole control surface for these styles; the layout
// mini-bar would be a duplicate. Clear on page destroy so the mini-bar returns
// on other pages (library, catalogue, etc.) where audio may still be playing.
$effect(() => {
audioStore.suppressMiniBar = layout.playerStyle === 'float' || layout.playerStyle === 'minimal';
return () => { audioStore.suppressMiniBar = false; };
});
// ── Persist float overlay position across reloads ─────────────────────────────
const FLOAT_POS_KEY = 'reader_float_pos_v1';
if (browser) {
try {
const saved = localStorage.getItem(FLOAT_POS_KEY);
if (saved) {
const p = JSON.parse(saved) as { x: number; y: number };
if (typeof p.x === 'number' && typeof p.y === 'number') audioStore.floatPos = p;
}
} catch { /* ignore */ }
}
$effect(() => {
const pos = audioStore.floatPos;
if (browser && (pos.x !== 0 || pos.y !== 0)) {
try { localStorage.setItem(FLOAT_POS_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
}
});
// ── Scroll progress bar ──────────────────────────────────────────────────────
let scrollProgress = $state(0);
@@ -596,27 +623,27 @@
</button>
{#if audioExpanded}
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active}
<!-- Mini-player is already playing this chapter — don't duplicate controls -->
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span>Controls are in the player bar below.</span>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={cleanTitle}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
wordCount={wordCount}
onProRequired={() => { audioProRequired = true; }}
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.active && layout.playerStyle === 'standard'}
<!-- Mini-player is already playing this chapter — don't duplicate controls (standard/minimal mode) -->
<div class="px-4 py-3 flex items-center gap-2 text-sm text-(--color-muted)">
<svg class="w-4 h-4 text-(--color-brand) shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span>Controls are in the player bar below.</span>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={cleanTitle}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
wordCount={wordCount}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
</div>
@@ -797,7 +824,7 @@
{#if layout.readMode === 'scroll' && !layout.focusMode}
{@const atTop = scrollProgress <= 0.01}
{@const atBottom = scrollProgress >= 0.99}
<div class="fixed right-4 {audioStore.active ? 'bottom-[5.5rem]' : 'bottom-8'} z-40 flex flex-col gap-2 transition-all">
<div class="fixed right-4 {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[5.5rem]' : 'bottom-8'} z-40 flex flex-col gap-2 transition-all">
<!-- Up button / Prev chapter -->
{#if atTop && data.prev}
<a
@@ -856,7 +883,7 @@
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
{#if layout.focusMode}
<div class="fixed {audioStore.active ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
<div class="fixed {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
<!-- Prev chapter -->
{#if data.prev}
@@ -1169,7 +1196,7 @@
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
<div class="flex gap-1.5 flex-1">
{#each ([['standard', 'Standard'], ['float', 'Float']] as const) as [s, lbl]}
{#each ([['standard', 'Standard'], ['minimal', 'Minimal'], ['float', 'Float']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
@@ -1182,6 +1209,17 @@
{/each}
</div>
</div>
<div class="px-3 pb-2.5">
<p class="text-[11px] text-(--color-muted)/70 leading-snug">
{#if layout.playerStyle === 'standard'}
Full panel with voice picker and chapter browser.
{:else if layout.playerStyle === 'minimal'}
Compact bar: play/pause, seek, and time only.
{:else}
Draggable overlay — stays visible while you read.
{/if}
</p>
</div>
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">

View File

@@ -0,0 +1,11 @@
import type { PageServerLoad } from './$types';
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ url }) => {
const sort = url.searchParams.get('sort') ?? 'chapters';
const q = url.searchParams.get('q') ?? '';
const audioBooks = await getBooksWithAudioCount(200).catch(() => []);
return { audioBooks, sort, q };
};

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { audioStore } from '$lib/audio.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let q = $state(data.q);
let sort = $state(data.sort);
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch { return []; }
}
const filtered = $derived.by(() => {
let list = data.audioBooks;
// text filter
if (q.trim()) {
const needle = q.trim().toLowerCase();
list = list.filter(
({ book }) =>
book.title?.toLowerCase().includes(needle) ||
book.author?.toLowerCase().includes(needle)
);
}
// sort
if (sort === 'title') {
list = [...list].sort((a, b) => (a.book.title ?? '').localeCompare(b.book.title ?? ''));
} else if (sort === 'recent') {
list = [...list].sort((a, b) => {
const da = a.book.updated ?? a.book.created ?? '';
const db = b.book.updated ?? b.book.created ?? '';
return db.localeCompare(da);
});
}
// default: 'chapters' — already sorted by getBooksWithAudioCount
return list;
});
function playChapter(slug: string, chapter: number) {
audioStore.autoStartChapter = chapter;
goto(`/books/${slug}/chapters/${chapter}`);
}
function onSortChange(value: string) {
sort = value;
const params = new URLSearchParams();
if (value !== 'chapters') params.set('sort', value);
if (q.trim()) params.set('q', q.trim());
const qs = params.toString();
goto(`/listen${qs ? `?${qs}` : ''}`, { replaceState: true, noScroll: true });
}
function onSearch(e: Event) {
e.preventDefault();
const params = new URLSearchParams();
if (sort !== 'chapters') params.set('sort', sort);
if (q.trim()) params.set('q', q.trim());
const qs = params.toString();
goto(`/listen${qs ? `?${qs}` : ''}`, { replaceState: true, noScroll: true });
}
</script>
<svelte:head>
<title>Narrated Books — LibNovel</title>
</svelte:head>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<svg class="w-5 h-5 text-(--color-brand)" 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>
<h1 class="text-xl font-bold text-(--color-text)">Narrated Books</h1>
</div>
<p class="text-sm text-(--color-muted)">Books with generated TTS audio ready to listen</p>
</div>
<!-- Controls -->
<div class="flex flex-col sm:flex-row gap-3 mb-6">
<!-- Search -->
<form onsubmit={onSearch} class="flex-1 flex gap-2">
<input
type="search"
bind:value={q}
placeholder="Search by title or author…"
class="flex-1 min-w-0 px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder:text-(--color-muted) text-sm focus:outline-none focus:border-(--color-brand)/60 transition-colors"
/>
<button
type="submit"
class="px-4 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 text-sm transition-colors shrink-0"
>
Search
</button>
</form>
<!-- Sort -->
<div class="flex items-center gap-1 shrink-0">
{#each [['chapters', 'Most narrated'], ['title', 'AZ'], ['recent', 'Recent']] as [val, label]}
<button
type="button"
onclick={() => onSortChange(val)}
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors {sort === val
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40'}"
>
{label}
</button>
{/each}
</div>
</div>
<!-- Results count -->
{#if filtered.length > 0}
<p class="text-xs text-(--color-muted) mb-4">{filtered.length} book{filtered.length !== 1 ? 's' : ''}</p>
{/if}
<!-- Grid -->
{#if filtered.length === 0}
<div class="text-center py-20 text-(--color-muted)">
{#if q.trim()}
<p class="text-base font-semibold text-(--color-text) mb-2">No results for "{q}"</p>
<p class="text-sm">Try a different search term.</p>
{:else}
<p class="text-base font-semibold text-(--color-text) mb-2">No narrated books yet</p>
<p class="text-sm">Audio is generated as books are read. Check back soon.</p>
{/if}
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{#each filtered as { book, audioChapters }}
{@const genres = parseGenres(book.genres)}
<div class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all">
<a href="/books/{book.slug}" class="block">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
</div>
{/if}
<!-- Headphones badge -->
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
<svg class="w-3 h-3" 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>
{audioChapters} ch
</span>
</div>
</a>
<div class="p-2 flex flex-col gap-1 flex-1">
<a href="/books/{book.slug}" class="block">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</a>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
<!-- Actions -->
<div class="px-2 pb-2 flex gap-1.5">
<button
type="button"
onclick={() => playChapter(book.slug, 1)}
class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
aria-label="Listen from chapter 1"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Listen
</button>
<a
href="/books/{book.slug}"
class="flex items-center justify-center px-2 py-1.5 rounded-md bg-(--color-surface-3) hover:bg-(--color-surface) border border-(--color-border) hover:border-(--color-brand)/40 text-(--color-muted) hover:text-(--color-text) transition-colors"
title="Book info"
aria-label="Book info"
>
<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="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
</svg>
</a>
</div>
</div>
{/each}
</div>
{/if}