- BookPlayer : navigation par playlist (9 morceaux) au lieu de 11 chapitres - stopPropagation clavier → plus de saut 1→3→5 - Sommaire aligné avec titres des morceaux - Bouton back aligné avec clavier (toujours morceau précédent) - Admin chapitres : tags morceaux cliquables avec étoile primary - Admin liste chapitres : badges morceaux associés - Éditeur markdown en vue split par défaut Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
914 lines
25 KiB
Vue
914 lines
25 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>
|
|
<div class="reader-bar-title">
|
|
<span class="reader-bar-num">{{ trackIdx + 1 }}.</span>
|
|
{{ currentTrack?.title ?? '' }}
|
|
</div>
|
|
<span class="reader-bar-pages">{{ scrollPercent }}%</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="(track, i) in tracks"
|
|
:key="track.id"
|
|
class="sommaire-item"
|
|
:class="{ 'sommaire-item--active': trackIdx === i }"
|
|
@click="goToTrack(i)"
|
|
>
|
|
<span class="sommaire-num">{{ i + 1 }}</span>
|
|
{{ track.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="currentLyrics" class="lyrics-content" v-html="currentLyricsHtml" />
|
|
<div v-else-if="currentSong" class="lyrics-empty">
|
|
<p class="op-40 italic">Paroles à venir pour « {{ currentSong.title }} »</p>
|
|
</div>
|
|
<div v-else class="lyrics-empty">
|
|
<p class="op-40 italic">Aucun morceau sélectionné</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 ? trackIdx <= 0 : (currentPage <= 0 && trackIdx <= 0) }"
|
|
@click="isScrollMode ? prevTrack() : prevPage()"
|
|
:aria-label="isScrollMode ? 'Morceau précédent' : 'Page précédente'"
|
|
>
|
|
<div class="i-lucide-chevron-left h-5 w-5" />
|
|
</button>
|
|
|
|
<!-- Song disc -->
|
|
<div v-if="currentSong" 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">{{ currentSong.title }}</span>
|
|
</div>
|
|
<div v-else class="reader-song" />
|
|
|
|
<button
|
|
class="reader-nav-btn"
|
|
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx >= tracks.length - 1 : (currentPage >= totalPages - 1 && trackIdx >= tracks.length - 1) }"
|
|
@click="isScrollMode ? nextTrack() : nextPage()"
|
|
:aria-label="isScrollMode ? 'Morceau 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 ?? '← → morceaux · 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 trackIdx = 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'>('scroll')
|
|
const isScrollMode = computed(() => readingMode.value === 'scroll')
|
|
const scrollPercent = ref(0)
|
|
|
|
// 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, getPlaylistOrder } = useBookData()
|
|
const audioPlayer = useAudioPlayer()
|
|
const playerStore = usePlayerStore()
|
|
|
|
// ── Tracks: built from playlist order (songs), not chapters ──
|
|
const tracks = computed(() => {
|
|
return playerStore.playlist.map(song => ({
|
|
id: song.id,
|
|
title: song.title,
|
|
song,
|
|
}))
|
|
})
|
|
|
|
const currentTrack = computed(() => tracks.value[trackIdx.value] ?? null)
|
|
const currentSong = computed(() => currentTrack.value?.song ?? null)
|
|
|
|
const currentLyrics = computed(() => {
|
|
return currentSong.value?.lyrics?.trim() || ''
|
|
})
|
|
|
|
const currentLyricsHtml = computed(() => {
|
|
if (!currentLyrics.value) return ''
|
|
return currentLyrics.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>')
|
|
})
|
|
|
|
// ── Per-track color hues (9 tracks) ──
|
|
const trackHues: [number, number][] = [
|
|
[15, 35], // 1 Ce livre est une façon
|
|
[350, 15], // 2 De quel don nous parlons
|
|
[36, 50], // 3 Les asymétries
|
|
[170, 200], // 4 Inverser les flux
|
|
[220, 250], // 5 Ainsi soit-il
|
|
[270, 300], // 6 La croissance
|
|
[320, 345], // 7 Monnaie libre
|
|
[150, 170], // 8 Créer une économie
|
|
[190, 220], // 9 Coder la liberté
|
|
]
|
|
|
|
const sceneVars = computed(() => {
|
|
const [h1, h2] = trackHues[trackIdx.value] ?? trackHues[0]
|
|
return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number>
|
|
})
|
|
|
|
// ── 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 track changes
|
|
watch(trackIdx, async () => {
|
|
currentPage.value = 0
|
|
await nextTick()
|
|
await nextTick()
|
|
setTimeout(recalcPages, 100)
|
|
})
|
|
|
|
async function initReading() {
|
|
trackIdx.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 by tracks (songs) ──
|
|
let _skipSongWatch = false
|
|
|
|
function goToTrack(idx: number) {
|
|
if (idx < 0 || idx >= tracks.value.length) return
|
|
trackIdx.value = idx
|
|
currentPage.value = 0
|
|
showSommaire.value = false
|
|
// Scroll to top in scroll mode
|
|
if (isScrollMode.value && viewportEl.value) {
|
|
viewportEl.value.scrollTop = 0
|
|
}
|
|
// Play the song
|
|
const song = tracks.value[idx]?.song
|
|
if (song) {
|
|
_skipSongWatch = true
|
|
audioPlayer.loadAndPlay(song)
|
|
}
|
|
}
|
|
|
|
function nextTrack() {
|
|
if (trackIdx.value < tracks.value.length - 1) {
|
|
goToTrack(trackIdx.value + 1)
|
|
}
|
|
}
|
|
|
|
function prevTrack() {
|
|
if (trackIdx.value > 0) {
|
|
goToTrack(trackIdx.value - 1)
|
|
}
|
|
}
|
|
|
|
function nextPage() {
|
|
if (currentPage.value < totalPages.value - 1) {
|
|
triggerTurn()
|
|
currentPage.value++
|
|
}
|
|
else if (trackIdx.value < tracks.value.length - 1) {
|
|
goToTrack(trackIdx.value + 1)
|
|
}
|
|
}
|
|
|
|
function prevPage() {
|
|
if (currentPage.value > 0) {
|
|
triggerTurn()
|
|
currentPage.value--
|
|
}
|
|
else if (trackIdx.value > 0) {
|
|
goToTrack(trackIdx.value - 1)
|
|
}
|
|
}
|
|
|
|
function triggerTurn() {
|
|
isTurning.value = true
|
|
setTimeout(() => { isTurning.value = false }, 700)
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
// CRITICAL: stop propagation so useKeyboardShortcuts doesn't also fire
|
|
e.stopPropagation()
|
|
|
|
if (e.key === 'Escape') { close(); return }
|
|
|
|
if (e.key === ' ') {
|
|
e.preventDefault()
|
|
audioPlayer.togglePlayPause()
|
|
return
|
|
}
|
|
|
|
if (isScrollMode.value) {
|
|
if (e.key === 'ArrowRight') { e.preventDefault(); nextTrack() }
|
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevTrack() }
|
|
}
|
|
else {
|
|
if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() }
|
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); nextTrack() }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); prevTrack() }
|
|
}
|
|
}
|
|
|
|
// ── Touch / swipe ──
|
|
let touchStartX = 0
|
|
|
|
function onTouchStart(e: TouchEvent) {
|
|
touchStartX = e.changedTouches[0]?.screenX ?? 0
|
|
}
|
|
|
|
function onTouchEnd(e: TouchEvent) {
|
|
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
|
|
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)
|
|
if (playlist.length) {
|
|
_skipSongWatch = true
|
|
audioPlayer.loadAndPlay(playlist[0])
|
|
}
|
|
await initReading()
|
|
}
|
|
else {
|
|
overlayRef.value?.removeEventListener('touchstart', onTouchStart)
|
|
overlayRef.value?.removeEventListener('touchend', onTouchEnd)
|
|
if (resizeObs) { resizeObs.disconnect(); resizeObs = null }
|
|
}
|
|
})
|
|
|
|
// ── Sync: when song changes externally (persistent player controls), update trackIdx ──
|
|
watch(() => playerStore.currentSong, (song) => {
|
|
if (!song || !isOpen.value) return
|
|
if (_skipSongWatch) {
|
|
_skipSongWatch = false
|
|
return
|
|
}
|
|
const idx = tracks.value.findIndex(t => t.id === song.id)
|
|
if (idx !== -1 && idx !== trackIdx.value) {
|
|
trackIdx.value = idx
|
|
currentPage.value = 0
|
|
showSommaire.value = false
|
|
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>
|