|
|
|
|
@@ -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>
|
|
|
|
|
@@ -1235,15 +1357,6 @@
|
|
|
|
|
{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}
|
|
|
|
|
|