From 837b5394fefe1fb7141ef429fb81e15126ee9279 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sun, 22 Feb 2026 02:40:46 +0100 Subject: [PATCH] Add BookPlayer scroll reading mode and floating mini-player widget Replace paginated-only reading with a toggle between paginated (CSS columns) and continuous vertical scroll modes. Replace the full-width fixed footer player bar with a compact floating pill in the bottom-right corner, expandable to show full controls, visualizer, and playlist. Co-Authored-By: Claude Opus 4.6 --- app/assets/css/main.css | 2 +- app/components/book/BookPlayer.vue | 135 ++++++-- app/components/layout/TheFooter.vue | 2 +- app/components/player/PlayerPersistent.vue | 348 ++++++++++++++++----- app/layouts/default.vue | 2 +- app/layouts/reading.vue | 2 +- site/pages/book-player.yml | 2 + 7 files changed, 391 insertions(+), 102 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 8d30d5e..86f81c8 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -12,7 +12,7 @@ --color-text-muted: 0 0% 100% / 0.6; --header-height: 4rem; - --player-height: 5rem; + --player-height: 0rem; --sidebar-width: 280px; --font-display: 'Syne', sans-serif; diff --git a/app/components/book/BookPlayer.vue b/app/components/book/BookPlayer.vue index be29123..931a254 100644 --- a/app/components/book/BookPlayer.vue +++ b/app/components/book/BookPlayer.vue @@ -39,12 +39,21 @@ >
+
{{ chapterIdx + 1 }}. {{ chapters[chapterIdx].title }}
- {{ currentPage + 1 }}/{{ totalPages }} + +
@@ -68,25 +77,31 @@ -
+
- -
+ +
@@ -103,9 +118,9 @@ @@ -114,8 +129,14 @@

- - {{ bpContent?.reader.hints.mobile }} + +

@@ -146,6 +167,31 @@ const colWidth = ref(500) const showSommaire = ref(false) const isTurning = ref(false) +// ── Reading mode ── +const readingMode = ref<'paginated' | 'scroll'>('paginated') +const isScrollMode = computed(() => readingMode.value === 'scroll') +const scrollPercent = ref(0) + +function toggleReadingMode() { + readingMode.value = readingMode.value === 'paginated' ? 'scroll' : 'paginated' +} + +// When switching back to paginated, recalc pages +watch(readingMode, async (mode) => { + if (mode === 'paginated') { + await nextTick() + await nextTick() + setTimeout(recalcPages, 100) + } +}) + +function onViewportScroll() { + if (!isScrollMode.value || !viewportEl.value) return + const el = viewportEl.value + const max = el.scrollHeight - el.clientHeight + scrollPercent.value = max > 0 ? Math.round((el.scrollTop / max) * 100) : 0 +} + const { init: initBookData, getSongs, getPrimarySong, getPlaylistOrder } = useBookData() const audioPlayer = useAudioPlayer() const playerStore = usePlayerStore() @@ -213,13 +259,17 @@ const chapterSong = computed(() => { }) // ── CSS columns pagination ── -const contentStyle = computed(() => ({ - columnWidth: colWidth.value + 'px', - columnGap: COL_GAP + 'px', - transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`, -})) +const contentStyle = computed(() => { + if (isScrollMode.value) return {} + return { + columnWidth: colWidth.value + 'px', + columnGap: COL_GAP + 'px', + transform: `translateX(-${currentPage.value * (colWidth.value + COL_GAP)}px)`, + } +}) function recalcPages() { + if (isScrollMode.value) return if (!contentEl.value || !viewportEl.value) return colWidth.value = viewportEl.value.offsetWidth const sw = contentEl.value.scrollWidth @@ -256,11 +306,27 @@ function goToChapter(idx: number) { chapterIdx.value = idx currentPage.value = 0 showSommaire.value = false + // Scroll to top in scroll mode + if (isScrollMode.value && viewportEl.value) { + viewportEl.value.scrollTop = 0 + } // Play chapter song const song = getPrimarySong(chapters[idx].slug) if (song) audioPlayer.loadAndPlay(song) } +function nextChapter() { + if (chapterIdx.value < chapters.length - 1) { + goToChapter(chapterIdx.value + 1) + } +} + +function prevChapter() { + if (chapterIdx.value > 0) { + goToChapter(chapterIdx.value - 1) + } +} + function nextPage() { if (currentPage.value < totalPages.value - 1) { triggerTurn() @@ -306,11 +372,20 @@ function close() { } function handleKeydown(e: KeyboardEvent) { - if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() } - else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() } - else if (e.key === 'ArrowDown') { e.preventDefault(); if (chapterIdx.value < chapters.length - 1) goToChapter(chapterIdx.value + 1) } - else if (e.key === 'ArrowUp') { e.preventDefault(); if (chapterIdx.value > 0) goToChapter(chapterIdx.value - 1) } - else if (e.key === 'Escape') close() + if (isScrollMode.value) { + // Scroll mode: left/right = chapters, up/down = natural scroll (no preventDefault) + if (e.key === 'ArrowRight') { e.preventDefault(); nextChapter() } + else if (e.key === 'ArrowLeft') { e.preventDefault(); prevChapter() } + else if (e.key === 'Escape') close() + } + else { + // Paginated mode: left/right = pages, up/down = chapters + if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() } + else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() } + else if (e.key === 'ArrowDown') { e.preventDefault(); nextChapter() } + else if (e.key === 'ArrowUp') { e.preventDefault(); prevChapter() } + else if (e.key === 'Escape') close() + } } // ── Touch / swipe ── @@ -321,6 +396,8 @@ function onTouchStart(e: TouchEvent) { } function onTouchEnd(e: TouchEvent) { + // Disable page-swipe in scroll mode (vertical scroll is native) + if (isScrollMode.value) return const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0) if (Math.abs(diff) > 50) { if (diff > 0) nextPage() @@ -389,7 +466,7 @@ onUnmounted(() => { align-items: center; outline: none; overflow: hidden; - padding-bottom: 4.5rem; + padding-bottom: 1rem; transition: --scene-h1 1.6s ease, --scene-h2 1.6s ease; } @@ -492,7 +569,7 @@ onUnmounted(() => { /* ─── HINT ─── */ .bp-hint { position: absolute; - bottom: 5rem; + bottom: 0.5rem; left: 50%; transform: translateX(-50%); z-index: 10; @@ -678,6 +755,16 @@ onUnmounted(() => { max-width: none; } +/* ─── Scroll mode overrides ─── */ +.reader-viewport--scroll { + overflow-y: auto; +} +.reader-columns--scroll { + height: auto; + column-fill: unset; + transition: none; +} + /* Tighten prose for column context */ .reader-columns :deep(h1) { font-size: clamp(1.5rem, 3.5vw, 2rem); diff --git a/app/components/layout/TheFooter.vue b/app/components/layout/TheFooter.vue index a7a54c3..74fb45f 100644 --- a/app/components/layout/TheFooter.vue +++ b/app/components/layout/TheFooter.vue @@ -1,5 +1,5 @@ diff --git a/app/layouts/default.vue b/app/layouts/default.vue index bf957b5..f3e7b5b 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,7 +1,7 @@