Files
libnovel/ui/src/routes/+layout.svelte
Admin b87e758303 feat(ui): auto-next chapter navigation with auto-start audio
When autoNext is enabled, audio automatically advances to the next chapter
when a track ends — navigating the page and starting the new chapter's audio.

- audioStore: add autoNext (toggle flag), nextChapter (written by AudioPlayer),
  and autoStartPending (set before goto, cleared after auto-start fires)
- layout: update onended to call goto() and set autoStartPending when autoNext
  is on and nextChapter is available; add auto-next toggle button to mini-player
  bar (double-chevron icon, amber when active)
- AudioPlayer: accept nextChapter prop; write it to audioStore via $effect;
  auto-start playback when autoStartPending is set on component mount; show
  inline 'Auto' toggle button in ready controls when a next chapter exists
- Chapter page: pass data.next as nextChapter prop to AudioPlayer
2026-03-04 16:01:26 +05:00

340 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
// The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null);
// Keep the audio element's playback rate in sync with the store speed.
$effect(() => {
if (audioEl) audioEl.playbackRate = audioStore.speed;
});
// When audioUrl changes, load the new source.
// Use a local variable to track which URL is currently loaded so we never
// compare against audioEl.src (browsers normalise it, causing false mismatches).
let loadedUrl = '';
$effect(() => {
if (!audioEl) return;
const url = audioStore.audioUrl;
if (url && url !== loadedUrl) {
loadedUrl = url;
audioEl.src = url;
audioEl.load();
audioEl.playbackRate = audioStore.speed;
audioEl.play().catch(() => {});
} else if (!url) {
loadedUrl = '';
}
});
// Handle toggle requests from AudioPlayer controller.
$effect(() => {
// Read toggleRequest to subscribe; ignore value 0 (initial).
const _req = audioStore.toggleRequest;
if (!audioEl || _req === 0) return;
if (audioStore.isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch(() => {});
}
});
// Handle seek requests from AudioPlayer controller.
$effect(() => {
const t = audioStore.seekRequest;
if (!audioEl || t === null) return;
audioEl.currentTime = t;
audioStore.seekRequest = null;
});
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function togglePlay() {
if (!audioEl) return;
if (audioStore.isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch(() => {});
}
}
function seek(e: Event) {
if (!audioEl) return;
audioEl.currentTime = parseFloat((e.target as HTMLInputElement).value);
}
function skipBack() {
if (!audioEl) return;
audioEl.currentTime = Math.max(0, audioEl.currentTime - 15);
}
function skipForward() {
if (!audioEl) return;
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
}
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed);
audioStore.speed = speedSteps[(idx + 1) % speedSteps.length];
}
function dismiss() {
if (audioEl) {
audioEl.pause();
audioEl.src = '';
}
audioStore.status = 'idle';
audioStore.audioUrl = '';
audioStore.slug = '';
audioStore.chapter = 0;
audioStore.isPlaying = false;
audioStore.currentTime = 0;
audioStore.duration = 0;
}
</script>
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title>
</svelte:head>
<!-- Persistent audio element — always in the DOM, never conditionally unmounted.
Conditional rendering ({#if}) would destroy/recreate it when reactive state
changes (e.g. currentTime ticking), triggering onpause and restarting audio. -->
<audio
bind:this={audioEl}
bind:currentTime={audioStore.currentTime}
bind:duration={audioStore.duration}
onplay={() => (audioStore.isPlaying = true)}
onpause={() => (audioStore.isPlaying = false)}
onended={() => {
audioStore.isPlaying = false;
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
audioStore.autoStartPending = true;
goto(`/books/${audioStore.slug}/chapters/${audioStore.nextChapter}`);
}
}}
preload="metadata"
style="display:none"
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300">
libnovel
</a>
{#if data.user}
<a href="/books" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
Library
</a>
<a href="/browse" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
Discover
</a>
<div class="ml-auto flex items-center gap-4">
<span class="text-zinc-400 text-sm hidden sm:block">{data.user.username}</span>
<form method="POST" action="/logout">
<button
type="submit"
class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors"
>
Sign out
</button>
</form>
</div>
{:else}
<div class="ml-auto">
<a
href="/login"
class="text-sm px-3 py-1.5 rounded bg-amber-400 text-zinc-900 font-semibold hover:bg-amber-300 transition-colors"
>
Sign in
</a>
</div>
{/if}
</nav>
</header>
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{#key page.url.pathname + page.url.search}
{@render children()}
{/key}
</main>
<footer class="border-t border-zinc-800 text-zinc-600 text-xs text-center py-4">
libnovel
</footer>
</div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl">
<!-- 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">
<div
class="h-full bg-amber-400 transition-none"
style="width: {audioStore.progress}%"
></div>
</div>
{:else if audioStore.status === 'ready'}
<!-- Seek bar flush at top — tappable on mobile -->
<div class="px-0">
<input
type="range"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1 accent-amber-400 cursor-pointer block"
style="margin: 0; border-radius: 0;"
/>
</div>
{/if}
<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">
{#if audioStore.chapterTitle}
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
{/if}
{#if audioStore.bookTitle}
<p class="text-xs text-zinc-500 truncate leading-tight">{audioStore.bookTitle}</p>
{/if}
{#if audioStore.status === 'generating'}
<p class="text-xs text-amber-400 leading-tight">
Generating… {Math.round(audioStore.progress)}%
</p>
{:else if audioStore.status === 'ready'}
<p class="text-xs text-zinc-500 tabular-nums leading-tight">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</p>
{:else if audioStore.status === 'loading'}
<p class="text-xs text-zinc-500 leading-tight">Loading…</p>
{/if}
</div>
{#if audioStore.status === 'ready'}
<!-- Skip back 15s -->
<button
onclick={skipBack}
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
title="Back 15s"
aria-label="Rewind 15 seconds"
>
<svg class="w-5 h-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"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
<!-- Play / Pause -->
<button
onclick={togglePlay}
class="w-10 h-10 rounded-full bg-amber-400 text-zinc-900 flex items-center justify-center hover:bg-amber-300 transition-colors flex-shrink-0"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#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
onclick={skipForward}
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
title="Forward 30s"
aria-label="Skip 30 seconds"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
</svg>
</button>
<!-- Speed control -->
<button
onclick={cycleSpeed}
class="text-xs font-semibold text-zinc-300 hover:text-amber-400 transition-colors px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 flex-shrink-0 tabular-nums w-12 text-center"
title="Change playback speed"
aria-label="Playback speed {audioStore.speed}x"
>
{audioStore.speed}×
</button>
<!-- Auto-next toggle -->
<button
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class="p-1.5 rounded flex-shrink-0 transition-colors {audioStore.autoNext
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
aria-pressed={audioStore.autoNext}
>
<!-- "skip to end" / auto-advance icon -->
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
</button>
{:else if audioStore.status === 'generating'}
<!-- Spinner during generation -->
<svg class="w-6 h-6 text-amber-400 animate-spin flex-shrink-0" 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>
{/if}
<!-- Go to chapter link (only when we know which chapter is playing) -->
{#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"
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>
</a>
{/if}
<!-- Dismiss -->
<button
onclick={dismiss}
class="text-zinc-600 hover:text-zinc-400 transition-colors p-1.5 rounded flex-shrink-0"
title="Close player"
aria-label="Close player"
>
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{/if}