Compare commits

...

3 Commits

Author SHA1 Message Date
root
7bcc481483 feat: listening settings tab controls, warm-up button, resume button
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m37s
Release / Gitea Release (push) Successful in 31s
- Listening tab: expose Speed, Auto-next, and Announce chapter controls
  alongside the existing Player Style row (chapters/[n]/+page.svelte)
- AudioPlayer: add Warm up Ch. N button in standard player ready state
  when autoNext is off; shows prefetching spinner and completion status
- Book page: fetch saved audioTime on mount; show Resume Ch. N button
  (desktop + mobile) that sets autoStartChapter and navigates directly
2026-04-08 15:14:51 +05:00
root
16f277354b fix: close missing {/if} for playerStyle block in AudioPlayer
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m40s
Release / Docker (push) Successful in 5m50s
Release / Gitea Release (push) Successful in 37s
The {#if playerStyle === 'compact'} block was left unclosed after the
idle-state refactor, causing a svelte-check parse error.
2026-04-08 14:45:56 +05:00
root
3c33b22511 feat: redesign standard player idle state as pill-style row
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 35s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Replace the two-row layout (toolbar + lonely 'Play narration' button)
with a single pill row:
- Large circular play button on the left (brand color, active:scale-95)
- 'Play narration' label + voice selector button + estimated duration
  (based on word count at ~150wpm) in the centre
- Chapters icon button on the right

Voice panel drops inline below the pill when open.
Non-idle states (loading / generating / ready / other-chapter-playing)
keep the existing toolbar + status layout unchanged.

Also adds wordCount prop to AudioPlayer and passes it from the chapter page.
2026-04-08 13:52:52 +05:00
3 changed files with 284 additions and 25 deletions

View File

@@ -72,6 +72,8 @@
onProRequired?: () => void;
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
playerStyle?: 'standard' | 'compact';
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
wordCount?: number;
}
let {
@@ -84,9 +86,13 @@
chapters = [],
voices = [],
onProRequired = undefined,
playerStyle = 'standard'
playerStyle = 'standard',
wordCount = 0
}: Props = $props();
/** Estimated listen time in minutes at ~150 wpm average narration speed. */
const estimatedMinutes = $derived(wordCount > 0 ? Math.max(1, Math.round(wordCount / 150)) : 0);
// ── Derived: voices grouped by engine ──────────────────────────────────
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
@@ -1063,6 +1069,134 @@
</div>
{:else}
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
{#if
(!audioStore.isCurrentChapter(slug, chapter) && !audioStore.active) ||
(audioStore.isCurrentChapter(slug, chapter) && (audioStore.status === 'idle' || audioStore.status === 'error'))
}
<!-- ── Idle / not-yet-started pill ─────────────────────────────────────────── -->
<div class="px-3 py-2.5">
{#if audioStore.isCurrentChapter(slug, chapter) && audioStore.status === 'error'}
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<div class="flex items-center gap-3">
<!-- Big play button -->
<button
type="button"
onclick={handlePlay}
class="w-11 h-11 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 shadow-sm"
aria-label={m.reader_play_narration()}
>
<svg class="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<!-- Track info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
{m.reader_play_narration()}
</p>
<div class="flex items-center gap-1.5 mt-0.5">
<!-- Voice indicator -->
{#if voices.length > 0}
<button
type="button"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
title={m.reader_change_voice()}
>
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span class="max-w-[90px] truncate">{voiceLabel(audioStore.voice)}</span>
<svg class={cn('w-2.5 h-2.5 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
{/if}
<!-- Estimated duration -->
{#if estimatedMinutes > 0}
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
{/if}
</div>
</div>
<!-- Chapters button (right side) -->
{#if chapters.length > 0}
<button
type="button"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
class={cn('flex items-center gap-1 px-2 py-1.5 rounded-md text-xs transition-colors flex-shrink-0', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)')}
title="Browse chapters"
>
<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="M4 6h16M4 10h16M4 14h10"/>
</svg>
<span class="hidden sm:inline">Chapters</span>
</button>
{/if}
</div>
<!-- Voice selector panel (inline below pill) -->
{#if showVoicePanel && voices.length > 0}
<div class="mt-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
onclick={() => { stopSample(); showVoicePanel = false; }}
aria-label={m.reader_close_voice_panel()}
>
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</Button>
</div>
<div class="max-h-64 overflow-y-auto">
{#if kokoroVoices.length > 0}
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
</div>
{#each kokoroVoices as v (v.id)}{@render voiceRow(v)}{/each}
{/if}
{#if pocketVoices.length > 0}
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
</div>
{#each pocketVoices as v (v.id)}{@render voiceRow(v)}{/each}
{/if}
{#if cfaiVoices.length > 0}
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
</div>
{#each cfaiVoices as v (v.id)}{@render voiceRow(v)}{/each}
{/if}
</div>
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-(--color-muted)">
{m.reader_voice_applies_next()}
{#if voices.length > 0}
<a
href="/api/audio/voice-samples"
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
onclick={(e) => {
e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
}}
>{m.reader_generate_samples()}</a>
{/if}
</p>
</div>
</div>
{/if}
</div>
{:else}
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
<div class="p-4">
<div class="flex items-center justify-end gap-2 mb-3">
<!-- Chapter picker button -->
@@ -1167,22 +1301,10 @@
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── This chapter is the active one ── -->
<!-- ── This chapter is the active one (non-idle states) ── -->
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
{#if audioStore.status === 'error'}
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{m.reader_play_narration()}
</Button>
{:else if audioStore.status === 'loading'}
{#if audioStore.status === 'loading'}
<Button variant="default" size="sm" disabled>
<svg class="w-3.5 h-3.5 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>
@@ -1223,6 +1345,47 @@
</span>
</div>
</div>
<!-- Warm-up next chapter (only when autoNext is off and next exists) -->
{#if !audioStore.autoNext && nextChapter != null}
<div class="mt-2 flex items-center gap-2">
{#if audioStore.nextStatus === 'none'}
<button
type="button"
onclick={prefetchNext}
class="flex items-center gap-1.5 text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
title="Pre-generate audio for the next chapter"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 2l10 10L7 22z"/>
</svg>
Warm up Ch. {nextChapter}
</button>
{:else if audioStore.nextStatus === 'prefetching'}
<span class="flex items-center gap-1.5 text-xs text-(--color-muted)">
<svg class="w-3.5 h-3.5 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>
Warming up Ch. {nextChapter}…
</span>
{:else if audioStore.nextStatus === 'prefetched'}
<span class="flex items-center gap-1.5 text-xs text-(--color-brand)">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
Ch. {nextChapter} ready
</span>
{:else if audioStore.nextStatus === 'failed'}
<span class="flex items-center gap-1.5 text-xs text-red-400">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
Warm-up failed
</span>
{/if}
</div>
{/if}
{/if}
{:else if audioStore.active}
@@ -1235,18 +1398,11 @@
{m.reader_load_this_chapter()}
</Button>
</div>
{:else}
<!-- ── Idle — nothing playing ── -->
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{m.reader_play_narration()}
</Button>
{/if}
</div>
{/if}
{/if}
<!-- /playerStyle -->
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that

View File

@@ -1,20 +1,43 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import StarRating from '$lib/components/StarRating.svelte';
import * as m from '$lib/paraglide/messages.js';
import type { ShelfName } from '$lib/server/pocketbase';
import { audioStore } from '$lib/audio.svelte';
let { data }: { data: PageData } = $props();
// ── Resume audio progress ──────────────────────────────────────────────────
/** audioTime > 5 means we have a saved position worth resuming */
let resumeAudioTime = $state<number | null>(null);
onMount(() => {
if (data.book?.slug) {
window.umami?.track('book_view', { slug: data.book.slug });
}
// Check if there's saved audio progress for the last chapter
if (data.book?.slug && data.lastChapter) {
fetch(`/api/progress/audio-time?slug=${encodeURIComponent(data.book.slug)}&chapter=${data.lastChapter}`)
.then((r) => r.ok ? r.json() : null)
.then((body) => {
if (body?.audioTime != null && body.audioTime > 5) {
resumeAudioTime = body.audioTime;
}
})
.catch(() => {});
}
});
function resumeAudio() {
if (!data.book?.slug || !data.lastChapter) return;
audioStore.autoStartChapter = data.lastChapter;
goto(`/books/${data.book.slug}/chapters/${data.lastChapter}`);
}
// ── Save / unsave ─────────────────────────────────────────────────────────
let saved = $state(untrack(() => data.saved));
let saving = $state(false);
@@ -644,6 +667,17 @@
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if resumeAudioTime != null && data.lastChapter}
<button
type="button"
onclick={resumeAudio}
class="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand) transition-colors"
title="Resume audio from where you left off"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Resume Ch. {data.lastChapter}
</button>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
@@ -745,6 +779,19 @@
{/if}
</div>
<!-- Resume audio row (mobile) -->
{#if resumeAudioTime != null && data.lastChapter}
<button
type="button"
onclick={resumeAudio}
class="flex items-center justify-center gap-1.5 w-full px-4 py-2.5 rounded-lg text-sm font-semibold bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand) transition-colors"
title="Resume audio from where you left off"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Resume Ch. {data.lastChapter}
</button>
{/if}
<!-- Row 2: bookmark + shelf + stars -->
<div class="flex items-center gap-2 flex-wrap">
{#if !data.isLoggedIn}

View File

@@ -526,6 +526,7 @@
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
wordCount={wordCount}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
@@ -950,6 +951,61 @@
</div>
</div>
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
<div class="flex gap-1.5 flex-1">
{#each ([[0.75, '0.75×'], [1, '1×'], [1.25, '1.25×'], [1.5, '1.5×'], [2, '2×']] as const) as [s, lbl]}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Auto-next</span>
<div class="flex gap-1.5 flex-1">
{#each ([[true, 'On'], [false, 'Off']] as const) as [v, lbl]}
<button
type="button"
onclick={() => { audioStore.autoNext = v; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.autoNext === v
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.autoNext === v}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Announce chapter -->
<div class="flex items-center gap-3 px-3 py-2.5 {!audioStore.autoNext ? 'opacity-40 pointer-events-none' : ''}">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Announce</span>
<div class="flex gap-1.5 flex-1">
{#each ([[true, 'On'], [false, 'Off']] as const) as [v, lbl]}
<button
type="button"
onclick={() => { audioStore.announceChapter = v; }}
disabled={!audioStore.autoNext}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.announceChapter === v
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.announceChapter === v}
>{lbl}</button>
{/each}
</div>
</div>
</div>
</div>