Estante 3D: mueble, lomos, logos y vista previa de tomos

This commit is contained in:
2026-11-06 09:28:23 -06:00
parent 66502409fe
commit bda8800a89
19 changed files with 1649 additions and 15 deletions
+123 -5
View File
@@ -364,6 +364,7 @@ window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); instal
// ---------- Router ----------
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
let shelfTrip = null // { mangaId } mientras se lee algo abierto desde el estante: libro 3D y vuelta al estante
async function router() {
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
if (cleanup) { cleanup(); cleanup = null }
@@ -371,6 +372,7 @@ async function router() {
const [pathPart, query] = location.hash.slice(1).split('?')
const parts = (pathPart || '/').split('/').filter(Boolean)
const params = new URLSearchParams(query || '')
if (parts[0] !== 'read' && parts[0] !== 'shelf') shelfTrip = null
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
if (!me) {
@@ -386,6 +388,7 @@ async function router() {
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params)
if (parts[0] === 'shelf') return await viewShelf()
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats()
@@ -2102,7 +2105,7 @@ async function viewReader(mangaId, chapterId, startPage) {
app.innerHTML = `
<div class="reader">
<div class="rbar top">
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
<a class="btn small" href="${shelfTrip?.mangaId === mangaId ? '#/shelf' : `#/manga/${mangaId}`}" title="${shelfTrip?.mangaId === mangaId ? 'Volver al estante' : 'Volver a la ficha'}">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select>
<button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
@@ -3067,7 +3070,8 @@ async function viewReader(mangaId, chapterId, startPage) {
}
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
const b3dWanted = () => B3D_OK && !!settings.book3d && isPaged() && settings.fit !== 'width'
const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
const b3dOn = () => b3dWanted() && !atEnd
const b3dPaper = () => {
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
@@ -3817,6 +3821,120 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
<div class="t">${esc(title)}</div>
${sub ? `<div class="sub">${sub}</div>` : ''}
</a>`
// ---------- Estante 3D ----------
const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles
// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado.
function handoffCover(src) {
document.querySelector('.handoff')?.remove()
const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' })
document.body.append(img)
const t0 = performance.now()
const fade = () => { img.classList.add('out'); setTimeout(() => img.remove(), 450) }
const wait = () => {
if (!img.isConnected) return
const ready = document.querySelector('#b3d:not([hidden]) canvas')
if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante
if (performance.now() - t0 > 2500 || !location.hash.startsWith('#/read/')) return fade()
setTimeout(wait, 60)
}
wait()
}
async function viewShelf() {
if (!SHELF_OK) {
app.innerHTML = `<h1>Estante 3D</h1><p class="muted">Este navegador no tiene WebGL2.</p><p><a class="btn" href="#/library">${icon('library')} Volver a la librería</a></p>`
return
}
document.body.classList.add('reading') // sin la barra de arriba
app.innerHTML = `<div id="shelf3d"></div><a class="btn small shelfback" href="#/library">${icon('back')} Librería</a><p class="shelfnote" id="shelfnote">Colocando los tomos…</p>
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
const note = $('#shelfnote'), card = $('#shelfpeek')
const focusOn = shelfTrip?.mangaId || null // al volver del lector, con ese tomo a la vista
shelfTrip = null
let shelf = null, gone = false
const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() }
document.addEventListener('keydown', onKey)
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); shelf?.destroy(); shelf = null }
// Vistazo: lo que se sabe del tomo sacado, junto a él.
const readOf = info => {
const { work, vol, state } = info
if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0)
if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0])
return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}`
}
// A la mesa: el estante posa el tomo como el lector lo mostrará y una foto del último fotograma tapa el cambio.
let leaving = false
const toTable = info => {
if (leaving) return
const href = readOf(info)
const go = snap => {
if (gone) return
if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id }
if (snap) handoffCover(snap)
location.hash = href
}
leaving = true
if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null)
}
const showPeek = info => {
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ')
const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0
const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar'
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
<h2 class="ptitle">${esc(work.title)}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
<div class="pacts"><a class="btn primary" id="peekread" href="${readOf(info)}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a><a class="btn" href="#/manga/${work.id}">Ver ficha</a></div>`
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
card.querySelector('#peekread').addEventListener('click', e => { e.preventDefault(); toTable(info) })
clearTimeout(card.hideTimer)
card.hidden = false
requestAnimationFrame(() => card.classList.add('on'))
}
const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || ''
try {
const [{ works }, lib, mod] = await Promise.all([api('/api/shelf'), import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
if (gone) return
if (!works.length) { note.textContent = 'Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.'; return }
shelf = mod.createShelf3D($('#shelf3d'), lib, {
works,
still: matchMedia('(prefers-reduced-motion: reduce)').matches,
onPeek: showPeek,
onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa
table: me?.tableV ? `/api/table?v=${me.tableV}` : null,
// Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos.
pagesOf: chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []),
})
if (!shelf) throw new Error('sin WebGL2')
if (focusOn) shelf.focus(focusOn)
// Los tomos llegan aparte, de pocos en pocos: la primera vez pueden tardar por las fuentes externas.
let next = 0, pending = works.length
const worker = async () => {
while (next < works.length && !gone) {
const w = works[next++]
const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
if (gone) return
if (r?.volumes?.length) shelf.setVolumes(w.id, r.volumes)
if (--pending === 0) note.hidden = true
}
}
for (let k = 0; k < 3; k++) worker()
// Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa.
let nextLogo = 0
const logoWorker = async () => {
while (nextLogo < works.length && !gone) {
const w = works[nextLogo++]
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
if (gone) return
if (r?.logo) shelf.setLogo(w.id, r.logo)
}
}
for (let k = 0; k < 2; k++) logoWorker()
} catch (e) {
if (!gone) note.textContent = `No se pudo abrir el estante: ${e.message}`
}
}
async function viewLibrary(params) {
const tab = params.get('tab') || 'reading'
app.innerHTML = '<div class="loading">Cargando…</div>'
@@ -3837,7 +3955,7 @@ async function viewLibrary(params) {
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
]
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
app.innerHTML = `<h1>Librería</h1>
app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('library')} Estante 3D</a>` : ''}</div>
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
@@ -4046,7 +4164,7 @@ async function viewQueues() {
</section>
<section class="qsec">
<h2>${icon('book')} Biblioteca descargada</h2>
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
<p class="muted small">CBZ en <code>./mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
${library.length ? `<div class="grid">${library.map(d => qcard({
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
@@ -4269,7 +4387,7 @@ function viewSettings() {
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
<form id="notify" style="display:grid;gap:8px">
<b style="font-size:.9rem">ntfy</b>
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off">
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://localhost:4546" autocomplete="off">
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>