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>
964 lines
27 KiB
Vue
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.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>
|