Estante 3D: entradas, orden A–Z, sin conexión, pósit y giroscopio calibrado
- Estante: entradas desde el menú, la librería y la ficha; orden reciente o A–Z; foto del estante, arte de lomos y portadas guardados en el dispositivo; sin conexión muestra solo lo guardado. - Notas de 40 letras sin cortar palabras, lomo curvo, sombra en las portadas de frente y lomos con logo sin el título. - Pósit cuadrado con el capítulo y la página por la que va el usuario; paso al lector sin pantalla de carga. - Giroscopio: integra rotationRate (sin los saltos de β/γ con el dispositivo vertical), tope suave, menos sensibilidad al caminar, lado en horizontal según la gravedad, suavizado por tiempo y más rango en el estante y el libro. - Laboratorio del giroscopio en Ajustes (temporal, solo desarrollo) para grabar los sensores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+35
-13
@@ -395,6 +395,7 @@ async function router() {
|
||||
if (parts[0] === 'stats') return await viewStats()
|
||||
if (parts[0] === 'clips') return await viewClips(params)
|
||||
if (parts[0] === 'settings') return viewSettings()
|
||||
if (parts[0] === 'gyrolab') return await viewGyroLab() // solo desarrollo
|
||||
return await viewHome(params)
|
||||
} catch (e) {
|
||||
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
|
||||
@@ -3075,6 +3076,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
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
|
||||
if (fromShelf) document.body.dataset.handoff = b3dWanted() ? '3d' : 'flat' // la foto del estante espera al libro 3D, o no (handoffCover)
|
||||
const b3dPaper = () => {
|
||||
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
|
||||
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
|
||||
@@ -3833,15 +3835,21 @@ function handoffCover(src) {
|
||||
document.body.append(img)
|
||||
document.body.classList.add('handing')
|
||||
const t0 = performance.now()
|
||||
let mounted = 0 // cuándo apareció el lector: si no va a haber libro 3D (modo vertical, error), no se espera más
|
||||
const fade = () => { document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
|
||||
let mounted = 0 // cuándo se supo que el lector se monta sin libro 3D
|
||||
delete document.body.dataset.handoff // lo dice el lector al montarse: '3d' o 'flat'
|
||||
const fade = why => { img.dataset.why = why; delete document.body.dataset.handoff; document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
|
||||
const wait = () => {
|
||||
if (!img.isConnected) return document.body.classList.remove('handing')
|
||||
const ready = document.querySelector('#b3d:not([hidden]) canvas')
|
||||
if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante
|
||||
if (!mounted && document.querySelector('.reader .rbar')) mounted = performance.now()
|
||||
const failed = !document.querySelector('.reader') && performance.now() - t0 > 400 // el lector no llegó a montarse (error)
|
||||
if (failed || (mounted && performance.now() - mounted > 1500) || performance.now() - t0 > 30000 || !location.hash.startsWith('#/read/')) return fade()
|
||||
if (ready) return setTimeout(() => fade('3d'), 350) // las texturas de la tapa tardan un instante
|
||||
// Montado sin libro 3D (modo vertical, ajuste a lo ancho): ya no habrá lienzo que esperar. Con él, se espera a que dibuje.
|
||||
if (!mounted && document.body.dataset.handoff === 'flat') mounted = performance.now()
|
||||
// El lector no llegó a montarse (un error en su lugar). Mientras siga el estante, la ruta aún no ha cambiado de vista.
|
||||
const failed = !document.querySelector('.reader, #shelf3d') && performance.now() - t0 > 400
|
||||
if (failed) return fade('error')
|
||||
if (mounted && performance.now() - mounted > 600) return fade('flat')
|
||||
if (performance.now() - t0 > 90000) return fade('tarde') // por si el lector se queda colgado: mientras carga, la foto sigue
|
||||
if (!location.hash.startsWith('#/read/')) return fade('ruta')
|
||||
setTimeout(wait, 60)
|
||||
}
|
||||
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
|
||||
@@ -3913,15 +3921,19 @@ async function viewShelf(params) {
|
||||
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'
|
||||
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : ''].filter(Boolean).join(' · ')
|
||||
// Por dónde va, escrito: el capítulo de la obra (si se sabe su número) y la página.
|
||||
const page = work.page > 0 ? `, página ${work.page + 1}` : ''
|
||||
const where = state === 'read' ? 'Ya leído'
|
||||
: state === 'reading' || state === 'unknown' ? (work.chapterNum != null && work.chapterNum !== '' ? `Vas por el capítulo ${esc(work.chapterNum)}${page}` : at >= 0 ? `Vas por el capítulo ${at + 1} de este tomo${page}` : 'Lo estás leyendo')
|
||||
: 'Sin empezar'
|
||||
const href = readOf(info), st = shelfMod.noteStyle(work.id)
|
||||
card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg`
|
||||
// Un pósit de verdad: sin botones. Se lee tocando el tomo (o esta línea, que lleva al mismo sitio).
|
||||
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
|
||||
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title, 60))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
|
||||
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
|
||||
<div class="pacts">${href ? `<a class="btn primary" id="peekread" href="${href}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a>` : '<span class="pwhere">No está guardado en el dispositivo</span>'}${offline ? '' : `<a class="btn" href="#/manga/${work.id}">Ver ficha</a>`}</div>`
|
||||
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
|
||||
<p class="pwhere">${where}</p>
|
||||
${href ? `<a class="phint" id="peekread" href="${href}">Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →</a>` : '<p class="phint">No está guardado en el dispositivo</p>'}`
|
||||
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
|
||||
card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) })
|
||||
clearTimeout(card.hideTimer)
|
||||
@@ -3978,7 +3990,7 @@ async function viewShelf(params) {
|
||||
const had = new Map(works.map(w => [w.id, w]))
|
||||
const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w))
|
||||
if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') }
|
||||
const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.page === w.page && o.last === w.last })
|
||||
const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.chapterNum === w.chapterNum && o.page === w.page && o.last === w.last })
|
||||
if (!shelf) start(next, lib)
|
||||
else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) }
|
||||
else works = next.map(w => had.get(w.id))
|
||||
@@ -4429,6 +4441,13 @@ async function tableJpeg(file) {
|
||||
return blob
|
||||
} finally { URL.revokeObjectURL(url) }
|
||||
}
|
||||
// Laboratorio del giroscopio: solo para desarrollo, se borrará (con public/gyrolab.js, gyrolab.js del servidor y su botón en Ajustes).
|
||||
async function viewGyroLab() {
|
||||
app.innerHTML = '<p class="muted">Cargando el laboratorio…</p>'
|
||||
const m = await import(`/gyrolab.js?v=${Date.now()}`)
|
||||
if (!location.hash.startsWith('#/gyrolab')) return
|
||||
cleanup = m.mountGyroLab(app, { esc, toast })
|
||||
}
|
||||
function viewSettings() {
|
||||
app.innerHTML = `<h1>Ajustes</h1>
|
||||
<div class="settings">
|
||||
@@ -4464,6 +4483,9 @@ function viewSettings() {
|
||||
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
|
||||
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
|
||||
</section>
|
||||
<section><b>${icon('phone')} Desarrollo: laboratorio del giroscopio</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">Graba los sensores de movimiento de este dispositivo para calibrar el giroscopio del estante y del libro 3D. Es temporal: se quitará al terminar.</p>
|
||||
<div class="row"><a class="btn small" href="#/gyrolab">${icon('phone')} Iniciar el laboratorio</a></div></section>
|
||||
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
|
||||
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
|
||||
|
||||
Reference in New Issue
Block a user