Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6c3b8c43 | ||
|
|
a47cc0e711 | ||
|
|
ac3d6e1784 | ||
|
|
adacd8944b | ||
|
|
ea58dab71c | ||
|
|
cf3a3ad910 | ||
|
|
8660c675b6 |
@@ -170,6 +170,21 @@ class AudioStore {
|
||||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||||
}
|
||||
|
||||
/**
|
||||
* When true the persistent mini-bar in +layout.svelte is hidden.
|
||||
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
|
||||
* so the in-page player is the sole control surface.
|
||||
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
|
||||
*/
|
||||
suppressMiniBar = $state(false);
|
||||
|
||||
/**
|
||||
* Position of the draggable float overlay (bottom-right anchor offsets).
|
||||
* Stored here (module singleton) so the position survives chapter navigation.
|
||||
* x > 0 = moved left; y > 0 = moved up.
|
||||
*/
|
||||
floatPos = $state({ x: 0, y: 0 });
|
||||
|
||||
/** True when the currently loaded track matches slug+chapter */
|
||||
isCurrentChapter(slug: string, chapter: number): boolean {
|
||||
return this.slug === slug && this.chapter === chapter;
|
||||
|
||||
@@ -945,24 +945,79 @@
|
||||
}
|
||||
|
||||
// ── Float player drag state ──────────────────────────────────────────────
|
||||
/** Position of the floating overlay (bottom-right anchor by default). */
|
||||
let floatPos = $state({ x: 0, y: 0 });
|
||||
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
|
||||
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
|
||||
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
|
||||
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
|
||||
//
|
||||
// To keep the circle in the viewport we clamp so that the element never goes
|
||||
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
|
||||
|
||||
const FLOAT_SIZE = 56; // px — must match w-14
|
||||
const FLOAT_MARGIN = 16; // px — 1rem
|
||||
|
||||
function clampFloatPos(x: number, y: number): { x: number; y: number } {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 400;
|
||||
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
|
||||
const maxX = FLOAT_MARGIN;
|
||||
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
|
||||
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
|
||||
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw;
|
||||
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
|
||||
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
|
||||
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh;
|
||||
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
|
||||
const maxY = FLOAT_MARGIN;
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
};
|
||||
}
|
||||
|
||||
let floatDragging = $state(false);
|
||||
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
|
||||
// Track total pointer movement to distinguish tap vs drag
|
||||
let floatMoved = $state(false);
|
||||
|
||||
function onFloatPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
floatDragging = true;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
|
||||
floatMoved = false;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onFloatPointerMove(e: PointerEvent) {
|
||||
if (!floatDragging) return;
|
||||
floatPos = {
|
||||
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
|
||||
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
|
||||
const dx = e.clientX - floatDragStart.mx;
|
||||
const dy = e.clientY - floatDragStart.my;
|
||||
// Only start moving if dragged > 6px to preserve tap detection
|
||||
if (!floatMoved && Math.hypot(dx, dy) < 6) return;
|
||||
floatMoved = true;
|
||||
const raw = {
|
||||
x: floatDragStart.ox - dx, // x increases toward left (away from right edge)
|
||||
y: floatDragStart.oy - dy, // y increases toward top (away from bottom edge)
|
||||
};
|
||||
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
|
||||
}
|
||||
function onFloatPointerUp() { floatDragging = false; }
|
||||
function onFloatPointerUp() {
|
||||
if (floatDragging && !floatMoved) {
|
||||
// Tap: toggle play/pause
|
||||
audioStore.toggleRequest++;
|
||||
}
|
||||
floatDragging = false;
|
||||
}
|
||||
|
||||
// Clamp saved position to viewport on mount and on resize
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const clamp = () => {
|
||||
audioStore.floatPos = clampFloatPos(audioStore.floatPos.x, audioStore.floatPos.y);
|
||||
};
|
||||
clamp();
|
||||
window.addEventListener('resize', clamp);
|
||||
return () => window.removeEventListener('resize', clamp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -1444,7 +1499,7 @@
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={chapter}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
@@ -1452,104 +1507,86 @@
|
||||
{/if}
|
||||
|
||||
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
||||
Rendered outside all containers so fixed positioning is never clipped.
|
||||
A draggable circle anchored to the viewport.
|
||||
Tap = toggle play/pause.
|
||||
Drag = reposition (clamped to viewport).
|
||||
Visible when playerStyle='float' and audio is active for this chapter. -->
|
||||
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed z-[55] select-none"
|
||||
style="
|
||||
bottom: calc(4.5rem + {-floatPos.y}px);
|
||||
right: calc(1rem + {-floatPos.x}px);
|
||||
bottom: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
|
||||
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
|
||||
touch-action: none;
|
||||
width: {FLOAT_SIZE}px;
|
||||
height: {FLOAT_SIZE}px;
|
||||
"
|
||||
onpointerdown={onFloatPointerDown}
|
||||
onpointermove={onFloatPointerMove}
|
||||
onpointerup={onFloatPointerUp}
|
||||
onpointercancel={onFloatPointerUp}
|
||||
>
|
||||
<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
|
||||
<!-- Drag handle + title row -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
|
||||
onpointerdown={onFloatPointerDown}
|
||||
onpointermove={onFloatPointerMove}
|
||||
onpointerup={onFloatPointerUp}
|
||||
onpointercancel={onFloatPointerUp}
|
||||
>
|
||||
<!-- Drag grip dots -->
|
||||
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||||
<!-- Pulsing ring when playing -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
|
||||
{/if}
|
||||
|
||||
<!-- Circle button -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center {floatDragging ? 'cursor-grabbing' : 'cursor-grab'} transition-transform active:scale-95"
|
||||
>
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-6 h-6 text-white 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>
|
||||
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
|
||||
{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
|
||||
</span>
|
||||
<!-- Status dot -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
class="mx-3 mb-2 h-1 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>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="flex items-center gap-1 px-3 pb-2.5">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-4 h-4" 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="w-9 h-9 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"
|
||||
>
|
||||
{#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
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="flex-1 text-[11px] text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)}
|
||||
<span class="opacity-50">/</span>
|
||||
{formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
|
||||
<!-- Speed -->
|
||||
<span class="text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0">
|
||||
{audioStore.speed}×
|
||||
</span>
|
||||
</div>
|
||||
{:else if audioStore.isPlaying}
|
||||
<!-- Pause icon -->
|
||||
<svg class="w-6 h-6 text-white pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Play icon -->
|
||||
<svg class="w-6 h-6 text-white ml-0.5 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress arc ring (thin, overlaid on circle edge) -->
|
||||
{#if audioStore.duration > 0}
|
||||
{@const r = 26}
|
||||
{@const circ = 2 * Math.PI * r}
|
||||
{@const dash = (audioStore.currentTime / audioStore.duration) * circ}
|
||||
<svg
|
||||
class="absolute inset-0 pointer-events-none -rotate-90"
|
||||
width={FLOAT_SIZE}
|
||||
height={FLOAT_SIZE}
|
||||
viewBox="0 0 {FLOAT_SIZE} {FLOAT_SIZE}"
|
||||
>
|
||||
<circle
|
||||
cx={FLOAT_SIZE / 2}
|
||||
cy={FLOAT_SIZE / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
stroke-width="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx={FLOAT_SIZE / 2}
|
||||
cy={FLOAT_SIZE / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{circ}"
|
||||
stroke-dashoffset="{circ - dash}"
|
||||
style="transition: stroke-dashoffset 0.5s linear;"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -512,7 +512,7 @@
|
||||
style="display:none"
|
||||
></audio>
|
||||
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
|
||||
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
|
||||
{#if navigating}
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
|
||||
@@ -570,20 +570,18 @@
|
||||
</a>
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Universal search button (hidden on chapter/reader pages) -->
|
||||
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
>
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Universal search button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
>
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user}
|
||||
@@ -745,14 +743,15 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
@@ -885,6 +884,17 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Backdrop for mobile hamburger menu — outside <header> so the blur
|
||||
only affects page content below, not the drawer items themselves -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={() => { menuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
|
||||
{#key page.url.pathname + page.url.search}
|
||||
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
|
||||
@@ -959,7 +969,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
|
||||
{#if audioStore.active}
|
||||
{#if audioStore.active && !audioStore.suppressMiniBar}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
@@ -1156,8 +1166,6 @@
|
||||
// Don't intercept when typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
|
||||
// Don't open on chapter reader pages
|
||||
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
|
||||
if (searchOpen) return;
|
||||
// `/` key or Cmd/Ctrl+K
|
||||
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {
|
||||
|
||||
@@ -100,6 +100,33 @@
|
||||
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
|
||||
});
|
||||
|
||||
// ── Suppress mini-bar for float / minimal player styles ──────────────────────
|
||||
// The in-page player is the sole control surface for these styles; the layout
|
||||
// mini-bar would be a duplicate. Clear on page destroy so the mini-bar returns
|
||||
// on other pages (library, catalogue, etc.) where audio may still be playing.
|
||||
$effect(() => {
|
||||
audioStore.suppressMiniBar = layout.playerStyle === 'float' || layout.playerStyle === 'minimal';
|
||||
return () => { audioStore.suppressMiniBar = false; };
|
||||
});
|
||||
|
||||
// ── Persist float overlay position across reloads ─────────────────────────────
|
||||
const FLOAT_POS_KEY = 'reader_float_pos_v1';
|
||||
if (browser) {
|
||||
try {
|
||||
const saved = localStorage.getItem(FLOAT_POS_KEY);
|
||||
if (saved) {
|
||||
const p = JSON.parse(saved) as { x: number; y: number };
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') audioStore.floatPos = p;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
$effect(() => {
|
||||
const pos = audioStore.floatPos;
|
||||
if (browser && (pos.x !== 0 || pos.y !== 0)) {
|
||||
try { localStorage.setItem(FLOAT_POS_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scroll progress bar ──────────────────────────────────────────────────────
|
||||
let scrollProgress = $state(0);
|
||||
|
||||
@@ -797,7 +824,7 @@
|
||||
{#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">
|
||||
<div class="fixed right-4 {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[5.5rem]' : 'bottom-8'} z-40 flex flex-col gap-2 transition-all">
|
||||
<!-- Up button / Prev chapter -->
|
||||
{#if atTop && data.prev}
|
||||
<a
|
||||
@@ -856,7 +883,7 @@
|
||||
|
||||
<!-- ── 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)]">
|
||||
<div class="fixed {audioStore.active && !audioStore.suppressMiniBar ? 'bottom-[4.5rem]' : 'bottom-6'} left-1/2 -translate-x-1/2 z-50 max-w-[calc(100vw-2rem)]">
|
||||
<div class="flex items-center divide-x divide-(--color-border) rounded-full bg-(--color-surface-2)/95 backdrop-blur border border-(--color-border) shadow-lg text-xs text-(--color-muted) overflow-hidden">
|
||||
<!-- Prev chapter -->
|
||||
{#if data.prev}
|
||||
|
||||
Reference in New Issue
Block a user