Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bcc481483 | ||
|
|
16f277354b |
@@ -1345,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}
|
||||
@@ -1360,6 +1401,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- /playerStyle -->
|
||||
|
||||
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
|
||||
Rendered as a top-level sibling (outside all player containers) so that
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -951,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user