Files
librodrome/app/components/book/BookPlayer.vue
Yvv 922afa2763 BookPlayer affiche les paroles du morceau, plus le contenu chapitre
Le BookPlayer chargeait les .md via Nuxt Content — qui contenaient avant
les paroles par erreur. Maintenant que les .md ont le vrai contenu du
livre, le BookPlayer doit afficher les lyrics depuis bookplayer.config.yml.

Supprime queryCollection('book') du BookPlayer, remplace ContentRenderer
par un rendu HTML des paroles avec tags stylisés.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:06:11 +01:00

964 lines
27 KiB
Vue

<template>
<Teleport to="body">
<Transition name="immersive">
<div
v-if="isOpen"
class="bp"
:style="sceneVars"
@keydown="handleKeydown"
tabindex="0"
ref="overlayRef"
>
<!-- Animated gradient background -->
<div class="bp-bg" aria-hidden="true">
<div class="bp-orb bp-orb--1" />
<div class="bp-orb bp-orb--2" />
<div class="bp-orb bp-orb--3" />
</div>
<!-- Central glow ring -->
<div
class="bp-glow"
:class="{ 'bp-glow--active': playerStore.isPlaying }"
aria-hidden="true"
/>
<!-- READER -->
<div class="bp-phase bp-reader">
<!-- Top bar -->
<div class="reader-bar">
<button
class="reader-bar-btn"
@click="showSommaire = !showSommaire"
aria-label="Sommaire"
>
<div class="i-lucide-list h-5 w-5" />
</button>
<button
class="reader-bar-btn"
@click="toggleReadingMode"
:aria-label="isScrollMode ? 'Mode paginé' : 'Mode défilement'"
:title="isScrollMode ? 'Mode paginé' : 'Mode défilement'"
>
<div :class="isScrollMode ? 'i-lucide-book-open' : 'i-lucide-scroll-text'" class="h-5 w-5" />
</button>
<div class="reader-bar-title">
<span class="reader-bar-num">{{ chapterIdx + 1 }}.</span>
{{ chapters[chapterIdx].title }}
</div>
<span class="reader-bar-pages">
<template v-if="isScrollMode">{{ scrollPercent }}%</template>
<template v-else>{{ currentPage + 1 }}<span class="op-40">/</span>{{ totalPages }}</template>
</span>
<button class="reader-bar-btn reader-bar-close" @click="close" aria-label="Fermer">
<div class="i-lucide-x h-5 w-5" />
</button>
</div>
<!-- Sommaire sidebar -->
<Transition name="sommaire">
<aside v-if="showSommaire" class="reader-sommaire" @click.self="showSommaire = false">
<div class="sommaire-panel">
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
<button
v-for="(ch, i) in chapters"
:key="i"
class="sommaire-item"
:class="{ 'sommaire-item--active': chapterIdx === i }"
@click="goToChapter(i)"
>
<span class="sommaire-num">{{ i + 1 }}</span>
{{ ch.title }}
</button>
</div>
</aside>
</Transition>
<!-- Content viewport -->
<div
class="reader-viewport"
:class="{ 'reader-viewport--scroll': isScrollMode }"
ref="viewportEl"
@scroll="onViewportScroll"
>
<div
class="reader-columns prose"
:class="{ 'reader-columns--scroll': isScrollMode }"
ref="contentEl"
:style="contentStyle"
>
<div v-if="chapterLyrics" class="lyrics-content" v-html="chapterLyricsHtml" />
<div v-else-if="chapterSong" class="lyrics-empty">
<p class="op-40 italic">Paroles à venir pour « {{ chapterSong.title }} »</p>
</div>
<div v-else class="lyrics-empty">
<p class="op-40 italic">Aucun morceau associé à ce chapitre</p>
</div>
</div>
<!-- Page turn shadow overlay (paginated only) -->
<div v-if="!isScrollMode" class="reader-shadow" :class="{ visible: isTurning }" />
</div>
<!-- Bottom navigation -->
<div class="reader-nav">
<button
class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': isScrollMode ? chapterIdx <= 0 : (currentPage <= 0 && chapterIdx <= 0) }"
@click="isScrollMode ? prevChapter() : prevPage()"
:aria-label="isScrollMode ? 'Chapitre précédent' : 'Page précédente'"
>
<div class="i-lucide-chevron-left h-5 w-5" />
</button>
<!-- Song disc (if chapter has a song) -->
<div v-if="chapterSong" class="reader-song">
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
<div class="reader-disc-hole" />
</div>
<span class="reader-song-name">{{ chapterSong.title }}</span>
</div>
<div v-else class="reader-song" />
<button
class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': isScrollMode ? chapterIdx >= chapters.length - 1 : (currentPage >= totalPages - 1 && chapterIdx >= chapters.length - 1) }"
@click="isScrollMode ? nextChapter() : nextPage()"
:aria-label="isScrollMode ? 'Chapitre suivant' : 'Page suivante'"
>
<div class="i-lucide-chevron-right h-5 w-5" />
</button>
</div>
</div>
<!-- Hint -->
<p class="bp-hint">
<template v-if="isScrollMode">
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktopScroll ?? '← → chapitres · Défilement libre · Esc fermer' }}</span>
<span class="md:hidden">{{ bpContent?.reader.hints.mobileScroll ?? 'Défilez pour lire' }}</span>
</template>
<template v-else>
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktop }}</span>
<span class="md:hidden">{{ bpContent?.reader.hints.mobile }}</span>
</template>
</p>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { data: bpContent } = await usePageContent('book-player')
const COL_GAP = 80
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const overlayRef = ref<HTMLElement>()
const viewportEl = ref<HTMLElement>()
const contentEl = ref<HTMLElement>()
const chapterIdx = ref(0)
const currentPage = ref(0)
const totalPages = ref(1)
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, getChapterForSong, getPlaylistOrder } = useBookData()
const audioPlayer = useAudioPlayer()
const playerStore = usePlayerStore()
// ── Content loaded flag (lyrics come from bookplayer config) ──
const contentLoaded = ref(false)
async function loadContent() {
if (contentLoaded.value) return
contentLoaded.value = true
}
// ── Chapter metadata ──
const chapters = [
{ slug: '01-introduction', title: 'Introduction' },
{ slug: '02-don', title: 'De quel don parlons-nous ?' },
{ slug: '03-mesure', title: 'La mesure du don' },
{ slug: '04-monnaie', title: 'Raison d\'être d\'une monnaie' },
{ slug: '05-trm', title: 'La TRM' },
{ slug: '06-economie', title: 'Créer une économie ?' },
{ slug: '07-echange', title: 'Échanger' },
{ slug: '08-institution', title: 'Relation institutionnelle' },
{ slug: '09-greffes', title: 'Autres greffes' },
{ slug: '10-maintenant', title: 'Et maintenant ?… action ?' },
{ slug: '11-annexes', title: 'Chapitres annexes' },
]
// ── Per-chapter color hues ──
const chapterHues: [number, number][] = [
[12, 36], // cover (intro + cover phases)
[15, 35], // 1
[350, 15], // 2
[36, 50], // 3
[170, 200], // 4
[220, 250], // 5
[270, 300], // 6
[320, 345], // 7
[150, 170], // 8
[190, 220], // 9
[40, 20], // 10
[210, 240], // 11
]
const sceneVars = computed(() => {
const idx = chapterIdx.value + 1
const [h1, h2] = chapterHues[idx] ?? chapterHues[0]
return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number>
})
const chapterSong = computed(() => {
return getPrimarySong(chapters[chapterIdx.value].slug)
})
const chapterLyrics = computed(() => {
return chapterSong.value?.lyrics?.trim() || ''
})
const chapterLyricsHtml = computed(() => {
if (!chapterLyrics.value) return ''
return chapterLyrics.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\[([^\]]+)\]/g, '<span class="lyrics-tag">[$1]</span>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^/, '<p>')
.replace(/$/, '</p>')
})
// ── CSS columns pagination ──
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
totalPages.value = Math.max(1, Math.round(sw / (colWidth.value + COL_GAP)))
}
let resizeObs: ResizeObserver | null = null
// Recalc when chapter changes
watch(chapterIdx, async () => {
currentPage.value = 0
await nextTick()
await nextTick()
setTimeout(recalcPages, 100)
})
async function initReading() {
await loadContent()
chapterIdx.value = 0
currentPage.value = 0
await nextTick()
await nextTick()
// Set up ResizeObserver
if (viewportEl.value && !resizeObs) {
resizeObs = new ResizeObserver(recalcPages)
resizeObs.observe(viewportEl.value)
}
setTimeout(recalcPages, 200)
}
// ── Navigation ──
let _skipSongWatch = false
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 (skip watcher to avoid loop)
const song = getPrimarySong(chapters[idx].slug)
if (song) {
_skipSongWatch = true
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()
currentPage.value++
}
else if (chapterIdx.value < chapters.length - 1) {
// Next chapter
goToChapter(chapterIdx.value + 1)
}
}
function prevPage() {
if (currentPage.value > 0) {
triggerTurn()
currentPage.value--
}
else if (chapterIdx.value > 0) {
// Previous chapter, go to last page
chapterIdx.value--
currentPage.value = 0
showSommaire.value = false
const song = getPrimarySong(chapters[chapterIdx.value].slug)
if (song) {
_skipSongWatch = true
audioPlayer.loadAndPlay(song)
}
// After content loads, go to last page
watch(chapterIdx, async () => {
await nextTick()
await nextTick()
setTimeout(() => {
recalcPages()
currentPage.value = Math.max(0, totalPages.value - 1)
}, 150)
}, { once: true })
}
}
function triggerTurn() {
isTurning.value = true
setTimeout(() => { isTurning.value = false }, 700)
}
function close() {
isOpen.value = false
}
function handleKeydown(e: KeyboardEvent) {
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 ──
let touchStartX = 0
function onTouchStart(e: TouchEvent) {
touchStartX = e.changedTouches[0]?.screenX ?? 0
}
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()
else prevPage()
}
}
// ── Lifecycle ──
watch(isOpen, async (open) => {
if (open) {
showSommaire.value = false
contentLoaded.value = false
await initBookData()
await nextTick()
overlayRef.value?.focus()
overlayRef.value?.addEventListener('touchstart', onTouchStart, { passive: true })
overlayRef.value?.addEventListener('touchend', onTouchEnd, { passive: true })
// Load playlist & play first song
const playlist = getPlaylistOrder()
if (playlist.length) playerStore.setPlaylist(playlist)
const first = getSongs().find(s => s.id === 'ce-livre-est-une-facon')
if (first) {
_skipSongWatch = true
audioPlayer.loadAndPlay(first)
}
// Start reading directly
await initReading()
}
else {
overlayRef.value?.removeEventListener('touchstart', onTouchStart)
overlayRef.value?.removeEventListener('touchend', onTouchEnd)
if (resizeObs) { resizeObs.disconnect(); resizeObs = null }
}
})
// ── Sync: when song changes in player, navigate to matching chapter ──
watch(() => playerStore.currentSong, (song) => {
if (!song || !isOpen.value) return
if (_skipSongWatch) {
_skipSongWatch = false
return
}
const slug = getChapterForSong(song.id)
if (!slug) return
const idx = chapters.findIndex(ch => ch.slug === slug)
if (idx !== -1 && idx !== chapterIdx.value) {
chapterIdx.value = idx
currentPage.value = 0
if (isScrollMode.value && viewportEl.value) {
viewportEl.value.scrollTop = 0
}
}
})
watch(isOpen, (open) => {
if (import.meta.client) document.body.style.overflow = open ? 'hidden' : ''
})
onUnmounted(() => {
if (import.meta.client) document.body.style.overflow = ''
if (resizeObs) resizeObs.disconnect()
})
</script>
<style scoped>
/* Animatable hue properties */
@property --scene-h1 {
syntax: '<number>';
inherits: true;
initial-value: 12;
}
@property --scene-h2 {
syntax: '<number>';
inherits: true;
initial-value: 36;
}
/* ═══════════════════════════════════════
BASE OVERLAY
═══════════════════════════════════════ */
.bp {
position: fixed;
inset: 0;
z-index: 60;
background: hsl(20 8% 3%);
display: flex;
flex-direction: column;
align-items: center;
outline: none;
overflow: hidden;
padding-bottom: 1rem;
transition: --scene-h1 1.6s ease, --scene-h2 1.6s ease;
}
/* ─── GRADIENT BACKGROUND ─── */
.bp-bg {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.bp-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
will-change: transform;
}
.bp-orb--1 {
width: 55vmax; height: 55vmax;
top: -20%; right: -15%;
background: hsl(var(--scene-h1) 65% 40% / 0.18);
animation: orb-1 22s ease-in-out infinite;
}
.bp-orb--2 {
width: 45vmax; height: 45vmax;
bottom: -25%; left: -12%;
background: hsl(var(--scene-h2) 55% 35% / 0.15);
animation: orb-2 28s ease-in-out infinite;
}
.bp-orb--3 {
width: 35vmax; height: 35vmax;
top: 35%; left: 25%;
background: hsl(calc((var(--scene-h1) + var(--scene-h2)) / 2) 45% 30% / 0.1);
animation: orb-3 18s ease-in-out infinite;
}
@keyframes orb-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-4%, 7%) scale(1.06); }
66% { transform: translate(3%, -4%) scale(0.94); }
}
@keyframes orb-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(7%, -3%) scale(0.95); }
66% { transform: translate(-5%, 5%) scale(1.06); }
}
@keyframes orb-3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-8%, -6%) scale(1.12); }
}
/* ─── GLOW RING ─── */
.bp-glow {
position: absolute;
top: 50%; left: 50%;
width: min(420px, 65vmin); height: min(420px, 65vmin);
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
box-shadow:
0 0 80px 25px hsl(var(--scene-h1) 65% 42% / 0.06),
0 0 160px 50px hsl(var(--scene-h2) 55% 38% / 0.04);
animation: glow-breathe 5s ease-in-out infinite;
}
.bp-glow--active {
box-shadow:
0 0 100px 35px hsl(var(--scene-h1) 70% 45% / 0.14),
0 0 200px 70px hsl(var(--scene-h2) 60% 40% / 0.08);
animation: glow-active 2.5s ease-in-out infinite;
}
@keyframes glow-breathe {
0%, 100% { opacity: 0.5; } 50% { opacity: 1; }
}
@keyframes glow-active {
0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.04); }
}
/* ─── HINT ─── */
.bp-hint {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
font-size: 0.68rem;
color: hsl(20 8% 28%);
white-space: nowrap;
}
/* ═══════════════════════════════════════
PHASE CONTAINER
═══════════════════════════════════════ */
.bp-phase {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
flex: 1;
min-height: 0;
}
/* ═══════════════════════════════════════
READER
═══════════════════════════════════════ */
.bp-reader {
width: 100%;
max-width: 52rem;
padding: 0 1rem;
gap: 0;
min-height: 0;
}
/* ─── Top bar ─── */
.reader-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
width: 100%;
}
.reader-bar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem; height: 2.25rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(20 8% 45%);
border: none;
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0;
}
.reader-bar-btn:hover {
color: white;
background: hsl(0 0% 100% / 0.06);
}
.reader-bar-close {
color: hsl(0 0% 100% / 0.7);
background: hsl(0 0% 100% / 0.08);
}
.reader-bar-close:hover {
color: white;
background: hsl(0 70% 55% / 0.3);
}
.reader-bar-title {
flex: 1;
font-family: var(--font-display, 'Syne', sans-serif);
font-size: clamp(0.85rem, 2.5vw, 1.1rem);
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reader-bar-num {
color: hsl(var(--scene-h1) 65% 55%);
margin-right: 0.375rem;
}
.reader-bar-pages {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: hsl(20 8% 40%);
flex-shrink: 0;
}
/* ─── Sommaire ─── */
.reader-sommaire {
position: absolute;
inset: 0;
z-index: 72;
background: hsl(20 8% 3% / 0.6);
backdrop-filter: blur(4px);
display: flex;
}
.sommaire-panel {
width: min(300px, 80vw);
height: 100%;
background: hsl(20 8% 6% / 0.95);
backdrop-filter: blur(16px);
border-right: 1px solid hsl(20 8% 14%);
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sommaire-title {
font-family: var(--font-display, 'Syne', sans-serif);
font-size: 0.85rem;
font-weight: 700;
color: hsl(20 8% 45%);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.75rem;
}
.sommaire-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.85rem;
color: hsl(20 8% 55%);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: all 0.25s;
width: 100%;
}
.sommaire-item:hover {
background: hsl(20 8% 12%);
color: white;
}
.sommaire-item--active {
background: hsl(var(--scene-h1) 50% 40% / 0.15);
color: white;
}
.sommaire-num {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem; height: 1.5rem;
border-radius: 50%;
font-size: 0.65rem;
font-weight: 700;
font-family: var(--font-mono, monospace);
color: hsl(20 8% 40%);
background: hsl(20 8% 12%);
flex-shrink: 0;
}
.sommaire-item--active .sommaire-num {
background: hsl(var(--scene-h1) 65% 42%);
color: white;
}
/* Sommaire slide transition */
.sommaire-enter-active { animation: sommaire-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; }
.sommaire-leave-active { animation: sommaire-in 0.25s cubic-bezier(0.7, 0, 0.84, 0) reverse both; }
@keyframes sommaire-in {
from { opacity: 0; }
to { opacity: 1; }
}
.sommaire-enter-active .sommaire-panel { animation: panel-slide 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; }
.sommaire-leave-active .sommaire-panel { animation: panel-slide 0.25s cubic-bezier(0.7, 0, 0.84, 0) reverse both; }
@keyframes panel-slide {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* ─── Content viewport ─── */
.reader-viewport {
position: relative;
flex: 1;
width: 100%;
overflow: hidden auto;
border-radius: 0.75rem;
background: hsl(20 8% 5% / 0.4);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.reader-columns {
height: 100%;
column-fill: auto;
padding: 2rem 2.5rem;
transition: transform 0.65s cubic-bezier(0.16, 1, 0.3, 1);
max-width: none;
}
/* ─── Scroll mode overrides ─── */
.reader-viewport--scroll {
overflow: hidden auto;
min-height: 0;
}
.reader-columns--scroll {
height: auto;
column-fill: unset;
column-width: unset !important;
transition: none;
}
/* Lyrics: preserve line breaks from \n in text nodes */
.reader-columns :deep(p) {
white-space: pre-line;
}
/* Tighten prose for column context */
.reader-columns :deep(h1) {
font-size: clamp(1.5rem, 3.5vw, 2rem);
margin-top: 0;
padding-bottom: 0.5rem;
}
.reader-columns :deep(h2) {
font-size: clamp(1.25rem, 3vw, 1.625rem);
margin-top: 2rem;
}
.reader-columns :deep(h3) {
font-size: clamp(1.065rem, 2.5vw, 1.25rem);
margin-top: 1.75rem;
}
/* Prevent awkward column breaks */
.reader-columns :deep(h2),
.reader-columns :deep(h3) {
break-after: avoid;
}
.reader-columns :deep(blockquote),
.reader-columns :deep(ul),
.reader-columns :deep(ol) {
break-inside: avoid;
}
/* Lyrics content */
.lyrics-content {
white-space: pre-line;
line-height: 1.9;
font-size: clamp(0.9rem, 2vw, 1.05rem);
}
.lyrics-content :deep(.lyrics-tag) {
display: block;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.35;
}
.lyrics-content :deep(p) {
break-inside: auto;
overflow-y: auto;
}
.lyrics-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.reader-columns :deep(p) {
break-inside: auto;
overflow-y: auto;
}
/* Page-turn shadow overlay */
.reader-shadow {
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 100%;
pointer-events: none;
opacity: 0;
background: linear-gradient(to left, hsl(20 8% 3% / 0.4), transparent);
transition: opacity 0.15s;
border-radius: 0 0.75rem 0.75rem 0;
}
.reader-shadow.visible {
opacity: 1;
}
/* ─── Bottom nav ─── */
.reader-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.25rem 0;
gap: 1rem;
}
.reader-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem; height: 2.5rem;
border-radius: 50%;
background: transparent;
color: hsl(20 8% 45%);
border: none;
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0;
}
.reader-nav-btn:hover {
background: hsl(0 0% 100% / 0.06);
color: white;
}
.reader-nav-btn--hidden {
opacity: 0;
pointer-events: none;
}
/* Song disc in nav */
.reader-song {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.reader-disc {
position: relative;
width: 2.25rem; height: 2.25rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
border: 2px solid hsl(20 8% 22%);
box-shadow: 0 0 10px hsl(var(--scene-h1) 50% 40% / 0.15);
}
.reader-disc.spinning {
animation: spin-slow 4s linear infinite;
}
.reader-disc-img {
width: 200%; height: 100%;
object-fit: cover;
transform: translateX(-50%);
}
.reader-disc-hole {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 0.5rem; height: 0.5rem;
border-radius: 50%;
background: hsl(20 8% 5%);
border: 1.5px solid hsl(20 8% 18%);
}
.reader-song-name {
font-size: 0.75rem;
color: hsl(20 8% 50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════
OVERLAY TRANSITIONS
═══════════════════════════════════════ */
.immersive-enter-active {
animation: bp-enter 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.immersive-leave-active {
animation: bp-enter 0.35s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
}
@keyframes bp-enter {
from { opacity: 0; transform: scale(1.02); }
to { opacity: 1; transform: scale(1); }
}
/* ─── MOBILE ─── */
@media (max-width: 768px) {
.bp-reader { padding: 0 0.5rem; }
.reader-columns { padding: 1.25rem 1rem; }
.bp-orb { filter: blur(80px); opacity: 0.7; }
.cover-frame { width: min(160px, 40vw); margin-bottom: 1.5rem; }
}
</style>