Compare commits

...

2 Commits

Author SHA1 Message Date
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
root
b0e23cb50a feat: floating scroll nav buttons in scroll reader mode
All checks were successful
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Successful in 1m38s
Release / Docker (push) Successful in 6m29s
Release / Gitea Release (push) Successful in 31s
Up/down chevron buttons fixed to the bottom-right of the viewport.
At the top of the chapter the up button becomes a Prev chapter link;
at the bottom the down button becomes an amber Next chapter link.
Hidden in focus mode (uses its own pill). Lifts above the audio
mini-player when it is active.
2026-04-11 15:52:14 +05:00
2 changed files with 164 additions and 28 deletions

View File

@@ -71,8 +71,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;
}
@@ -1034,7 +1037,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 +1095,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 +1172,64 @@
{: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 -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
class="flex-1 h-1.5 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>
<!-- 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 +1435,7 @@
</div>
{/if}
{/if}
{/if}
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that

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';
@@ -596,27 +596,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>
@@ -793,6 +793,67 @@
</div>
{/if}
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
{#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">
<!-- Up button / Prev chapter -->
{#if atTop && data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-brand) hover:bg-(--color-surface-3) transition-colors"
title="Previous chapter"
aria-label="Previous chapter"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</a>
{:else}
<button
type="button"
onclick={() => window.scrollBy({ top: -Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
disabled={atTop}
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
title="Scroll up"
aria-label="Scroll up"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
{/if}
<!-- Down button / Next chapter -->
{#if atBottom && data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-brand) shadow-lg text-black hover:bg-(--color-brand-dim) transition-colors"
title="Next chapter"
aria-label="Next chapter"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</a>
{:else}
<button
type="button"
onclick={() => window.scrollBy({ top: Math.round(window.innerHeight * 0.85), behavior: 'smooth' })}
disabled={atBottom && !data.next}
class="flex items-center justify-center w-10 h-10 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) shadow-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) disabled:opacity-20 disabled:cursor-default transition-colors"
title="Scroll down"
aria-label="Scroll down"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{/if}
</div>
{/if}
<!-- ── 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)]">
@@ -1108,7 +1169,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)}
@@ -1121,6 +1182,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">