Modo libro 3D, tomos con cubiertas reales y ocultar créditos

Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta,
hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor
del libro.

Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y
contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y
MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de
modo que solo quedan sueltos los capítulos del final, y los fuera de orden se
recolocan. Manhwa y manhua no tienen tomos.

Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por
tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo
si se repiten en otros capítulos de la obra.

HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 09:28:15 -06:00
co-authored by Claude Opus 5.5
parent bb1f3ff8e4
commit 66502409fe
10 changed files with 2430 additions and 58 deletions
+443 -54
View File
@@ -681,7 +681,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, hideCredits: creditsOn, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String))
let isFollowing = following
@@ -732,6 +732,7 @@ async function viewManga(id, langOverride) {
<div class="drawer-head"><b>Opciones</b><button class="btn small" id="libclose" title="Cerrar" aria-label="Cerrar">${icon('x')}</button></div>
<div class="drawer-opts">
<button class="btn" id="follow"></button>
<button class="btn" id="hidecred" hidden></button>
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
${menuItems}
@@ -1172,6 +1173,23 @@ async function viewManga(id, langOverride) {
b.hidden = offline || isBook
}
renderFollow()
// Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre.
let hideCredits = !!creditsOn
function renderCredits() {
const b = $('#hidecred')
if (!b) return
b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos`
b.classList.toggle('on', hideCredits)
b.setAttribute('aria-pressed', hideCredits)
b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.'
b.hidden = offline || !vols.length
}
$('#hidecred').addEventListener('click', async () => {
const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } })
hideCredits = r.hideCredits
renderCredits()
toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas')
})
$('#follow').addEventListener('click', async () => {
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
isFollowing = r.following
@@ -1251,13 +1269,59 @@ async function viewManga(id, langOverride) {
const d = new Date(e.t).toISOString().slice(0, 10)
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
}
// Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella.
let vols = [], volOf = new Map(), folded = new Set()
if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => {
if (!r.volumes?.length || !ul?.isConnected) return
vols = r.volumes
volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v])))
const open = volOf.get(progress?.chapter_id) || vols[0]
folded = new Set(vols.filter(v => v !== open).map(v => v.vol))
renderList()
renderCredits()
}, () => {})
const volHead = v => {
if (!v) return '<li class="volhead"><div class="voltoggle static"><b>Sin tomo</b></div></li>'
const inVol = chapters.filter(c => volOf.get(c.id) === v)
const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 }
const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id)
return `<li class="volhead"><button class="voltoggle" data-vol="${esc(v.vol)}" aria-expanded="${!folded.has(v.vol)}">
${v.thumb ? `<img src="${esc(v.thumb)}" alt="" loading="lazy">` : ''}<span><b>Tomo ${esc(v.vol)}</b><span class="meta">${plural(inVol.length, 'capítulo')}${read ? ` · ${read} leídos` : ''}</span></span>
</button><a class="btn small" href="${readUrl(id, from.id, r && !isDone(from) ? r.page : 0)}">${icon('book')} Leer tomo</a></li>`
}
ul?.addEventListener('click', e => {
const b = e.target.closest('button.voltoggle')
if (!b) return
folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol)
renderList()
})
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
const picking = sel?.mode === 'pick'
const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
ul.classList.toggle('picking', picking)
ul.innerHTML = list.map((c, i) => {
// Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera.
const seen = new Set()
const grouped = !vols.length ? list : list.flatMap(c => {
const v = volOf.get(c.id)
if (!v) return [c]
if (seen.has(v)) return []
seen.add(v)
return list.filter(x => volOf.get(x.id) === v)
})
let curVol
ul.innerHTML = grouped.map((c, i) => {
const v = volOf.get(c.id) || null
const head = vols.length && v !== curVol ? volHead(v) : ''
curVol = v
if (v && !picking && folded.has(v.vol)) return head
return head + chapterItem(c, i)
}).join('')
}
let pickable = new Set()
function chapterItem(c, i) {
const picking = sel?.mode === 'pick'
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
if (picking) {
@@ -1268,7 +1332,6 @@ async function viewManga(id, langOverride) {
}
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
}).join('')
}
// Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
ul?.addEventListener('click', e => {
@@ -1295,6 +1358,7 @@ async function viewManga(id, langOverride) {
}
// ---------- Lector ----------
const B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
// ---------- Sonido al pasar página ----------
@@ -1804,6 +1868,130 @@ async function createBook(text, mangaId, size) {
return book
}
// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción.
async function pageSig(url) {
const res = await fetch(url)
if (!res.ok) throw new Error('Sin imagen')
const bmp = await createImageBitmap(await res.blob())
const ar = bmp.width / bmp.height
// A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome.
let from = bmp, w = bmp.width, h = bmp.height, cv, cx
while (w > 64 || h > 64 || w !== h) {
w = Math.max(64, w >> 1)
h = Math.max(64, h >> 1)
cv = document.createElement('canvas')
cv.width = w
cv.height = h
cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 })
cx.imageSmoothingQuality = 'high'
cx.drawImage(from, 0, 0, w, h)
from = cv
}
bmp.close()
const d = cx.getImageData(0, 0, 64, 64).data
const out = new Uint8Array(256)
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
let sum = 0
for (let j = 0; j < 4; j++) {
for (let i = 0; i < 4; i++) {
const k = ((y * 4 + j) * 64 + x * 4 + i) * 4
sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2]
}
}
out[y * 16 + x] = Math.round(sum / 16)
}
}
return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 }
}
// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra.
async function creditPages(mangaId, parts) {
const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length }))
if (!chapters.length) return {}
const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } })
const first = await ask()
if (!first.missing.length) return first.hidden
const jobs = parts.filter(x => first.missing.includes(x.id))
const sigs = {}
let next = 0
await Promise.all(Array.from({ length: 3 }, async () => {
while (next < jobs.length) {
const x = jobs[next++], n = x.urls.length
try {
const pages = {}
for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i])
sigs[x.id] = pages
} catch {} // se volverá a intentar la próxima vez
}
}))
return (await ask(sigs)).hidden
}
// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo.
async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) {
const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))])
const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes
const vols = r?.volumes || []
const at = vols.findIndex(v => v.chapters.includes(chapterId))
if (at < 0) return null
const v = vols[at]
$('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`)
const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}`
const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id))
const parts = new Array(want.length)
let nextJob = 0
await Promise.all(Array.from({ length: 4 }, async () => {
while (nextJob < want.length) {
const k = nextJob++, { c, i } = want[k]
const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null)
if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages }
}
}))
const ok = parts.filter(Boolean)
if (!ok.some(x => x.id === chapterId)) return null
const cover = v.front ? await within(new Promise(res => {
const im = new Image()
im.onload = () => res(im.naturalWidth > 0)
im.onerror = () => res(false)
im.src = v.front
}), 15000) : false
// Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima.
const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {}
const pages = cover ? [v.front] : []
const starts = new Set()
for (const x of ok) {
const out = new Set(hidden[x.id] || [])
x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j))
x.start = pages.length
x.count = x.keep.length
starts.add(x.start)
pages.push(...x.keep.map(j => x.urls[j]))
}
const partOf = c => ok.find(x => x.i === c)
const tome = {
vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts,
has: c => !!partOf(c),
chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i },
chapterStart: c => partOf(c).start,
chapterPages: c => partOf(c).count,
// El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última
// visible es acabar el capítulo.
rawCount: c => partOf(c).urls.length,
rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] },
localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at },
// Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo.
async refresh(c) {
for (const x of ok) {
if (x.local || x.i < c || x.i > c + 1) continue
const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null)
if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]]
}
},
}
return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null }
}
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
@@ -1823,9 +2011,9 @@ async function viewReader(mangaId, chapterId, startPage) {
const { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
const idx = chapters.findIndex(c => c.id === chapterId)
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
let prev = idx > 0 ? chapters[idx - 1] : null
let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const isPaged = () => settings.mode !== 'vertical'
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
@@ -1844,7 +2032,23 @@ async function viewReader(mangaId, chapterId, startPage) {
if (location.hash !== opening) { book.release(); return }
book.busy = () => turning
}
const pages = chap.pages
// Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen
// siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto.
let tome = null, nextVol = null
if (!isBook && settings.tomes !== false && idx >= 0) {
const opening = location.hash
const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits)
if (location.hash !== opening) return
if (loaded) {
tome = loaded.tome
const first = tome.parts[0].i, last = tome.parts.at(-1).i
prev = first > 0 ? chapters[first - 1] : null
next = last < chapters.length - 1 ? chapters[last + 1] : null
nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo
}
}
const seq = book || tome // las páginas cruzan varios capítulos
const pages = tome ? tome.pages : chap.pages
const pageSrc = book ? i => book.src(i) : i => pages[i]
const total = book ? book.count : pages.length
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
@@ -1858,6 +2062,10 @@ async function viewReader(mangaId, chapterId, startPage) {
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
bookResume = null
}
if (tome) {
const local = tome.localPage(idx, Math.max(wanted, 0))
wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local
}
let page = Math.min(Math.max(wanted, 0), total - 1)
let shown = [page] // páginas visibles (2 en doble página)
const lastShown = () => (isPaged() ? shown.at(-1) : page)
@@ -1866,12 +2074,13 @@ async function viewReader(mangaId, chapterId, startPage) {
let saveTimer = null
const bookPayload = (c, local, count) => ({
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
})
const payload = () => {
if (!book) return mangaPayload()
const p = lastShown(), c = book.chapterAt(p)
return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
if (!seq) return mangaPayload()
const p = lastShown(), c = seq.chapterAt(p)
const local = Math.max(0, p - seq.chapterStart(c))
return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c))
}
const mangaPayload = () => ({
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
@@ -1886,7 +2095,9 @@ async function viewReader(mangaId, chapterId, startPage) {
window.addEventListener('pagehide', beacon)
saveNow()
let curChap = book ? book.chapterAt(page) : idx
let curChap = seq ? seq.chapterAt(page) : idx
const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0
const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown())
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
app.innerHTML = `
<div class="reader">
@@ -1905,6 +2116,8 @@ async function viewReader(mangaId, chapterId, startPage) {
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
${B3D_OK ? '<label class="row muted" style="font-size:.85rem" title="Muestra las páginas como un libro de verdad sobre una mesa"><input type="checkbox" id="b3dfx"> Libro 3D</label>' : ''}
${isBook ? '' : '<label class="row muted" style="font-size:.85rem" title="Lee los capítulos de un tomo como un solo libro, con su portada"><input type="checkbox" id="tomefx"> Por tomos</label>'}
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
@@ -1916,16 +2129,18 @@ async function viewReader(mangaId, chapterId, startPage) {
${OFFLINE_OK && !isBook ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
<p class="muted">${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
</div>
<div id="b3d" hidden></div>
<div id="pages"></div>
<div class="rbar bottom">
${book ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
${seq ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
<span id="pnum" class="muted"></span>
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
${book ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
${seq ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
</div>
</div>`
const reader = $('.reader', app)
let b3d = null, b3dLoading = false // el libro 3D, si está activo
const box = $('#pages', app)
const slider = $('#slider', app)
$('#mode').value = settings.mode
@@ -1944,14 +2159,15 @@ async function viewReader(mangaId, chapterId, startPage) {
const applyFilter = () => {
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
box.style.setProperty('--page-filter', f || 'none')
b3d?.setFilter(f)
reader.classList.toggle('bluelight', settings.filter === 'blue')
}
applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver
if ($('#hd')) $('#hd').checked = settings.hd !== false
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">${tome ? `Fin del tomo ${esc(tome.vol)}` : `Fin del capítulo ${esc(chapter.num ?? '')}`}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id, nextVol ? 0 : null)}">${icon('next')} Siguiente: ${nextVol ? `Tomo ${esc(nextVol.vol)}` : esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
@@ -1969,28 +2185,31 @@ async function viewReader(mangaId, chapterId, startPage) {
}
const syncBookChapter = () => {
const p = lastShown()
book.trim(p)
if (!isPaged()) {
box.querySelectorAll('img[src]').forEach(im => {
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
})
if (book) {
book.trim(p)
if (!isPaged()) {
box.querySelectorAll('img[src]').forEach(im => {
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
})
}
}
const c = book.chapterAt(p)
const c = seq.chapterAt(p)
if (c !== curChap) {
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
for (let j = curChap; j < c; j++) {
const count = book.chapterPages(j)
const count = tome ? tome.rawCount(j) : seq.chapterPages(j)
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
}
}
curChap = c
if (tome) { chapterId = chapters[c].id; chapter = chapters[c] }
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
}
lastBookPage = p
syncBookNav()
}
const setPageLabel = () => {
if (book) syncBookChapter()
if (seq) syncBookChapter()
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1
}
@@ -2015,7 +2234,8 @@ async function viewReader(mangaId, chapterId, startPage) {
// ancha ocupa las dos caras.
function spreadOf(i) {
for (let s = 0; s < total;) {
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
// En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre.
const g = s === 0 || tome?.starts.has(s) || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (g.includes(i)) return g
s += g.length
}
@@ -2054,7 +2274,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const spread = spreadOf(i)
return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l'
}
const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
const refreshGutters = () => { box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }); if (b3dOn()) b3d?.show(shown) }
// Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista.
function isPan(old) {
const imgs = old.querySelectorAll('img')
@@ -2077,7 +2297,7 @@ async function viewReader(mangaId, chapterId, startPage) {
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pageSrc(i)}" alt="" draggable="false">`).join('')
const imgs = [...stage.querySelectorAll('img')]
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página
const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {})))
@@ -2110,8 +2330,11 @@ async function viewReader(mangaId, chapterId, startPage) {
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag && !isPan(old)) turnSound(settings.sfx, turnPan(dir))
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
if (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir))
if (b3dOn()) {
box.replaceChildren(stage)
syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag)
} else if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i)
@@ -2372,7 +2595,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// giro daría un tirón. Una página que no llegó a subirse se sube al girar.
function scheduleLeafTextures(delay = 300) {
clearTimeout(leafTimer)
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay)
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay)
}
function nextLeafTexture() {
if (turning) return scheduleLeafTextures(400)
@@ -2794,7 +3017,7 @@ async function viewReader(mangaId, chapterId, startPage) {
turning = { anims, cleanup }
anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
}
function showEnd() {
function showEnd(dir = 0) {
finishTurn()
showSeq++ // descarta una página que estuviera esperando a cargar
clearTimeout(waitTimer)
@@ -2802,12 +3025,14 @@ async function viewReader(mangaId, chapterId, startPage) {
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima
else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir))
syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag)
}
function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) {
if (n >= total) return showEnd()
if (n >= total) return showEnd(dir)
n = Math.min(Math.max(n, 0), total - 1)
const g = groupOf(n)
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
@@ -2816,7 +3041,7 @@ async function viewReader(mangaId, chapterId, startPage) {
shown = g
page = g[0]
setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0)
if (changed) scheduleSave()
return
}
@@ -2830,7 +3055,7 @@ async function viewReader(mangaId, chapterId, startPage) {
}
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return }
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
@@ -2841,6 +3066,67 @@ async function viewReader(mangaId, chapterId, startPage) {
$('.rbar.bottom').classList.toggle('rtl', rtl)
}
// --- 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 b3dOn = () => b3dWanted() && !atEnd
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)
}
// El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve.
function placeEnd(at, colors) {
const el = $('.chapter-end', box)
if (!el) return
el.classList.toggle('on', !!at)
if (!at) return
const o = box.getBoundingClientRect()
el.style.width = `${at.width}px`
el.style.height = `${at.height}px`
el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)`
if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v)
}
function syncB3d(dir = 0, drag = false) {
const on = b3dWanted(), host = $('#b3d', reader)
if (!host) return
reader.classList.toggle('b3d', on)
host.hidden = !on
if (!b3dWanted()) {
b3d?.destroy()
b3d = null
return
}
// Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces.
clearTimeout(leafTimer)
for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex)
leafTextures.clear()
if (b3d) {
if (!on) return
const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag)
if (ctl) turning = ctl
return
}
if (b3dLoading) return
b3dLoading = true
import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => {
b3dLoading = false
if (!b3dWanted() || !box.isConnected) return
b3d = m.createBook3D(host, {
total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i),
dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd,
})
if (!b3d) throw new Error('sin WebGL2')
applyFilter()
syncB3d()
}).catch(err => {
console.warn('Libro 3D:', err)
b3dLoading = false
settings.book3d = false
saveGlobal()
if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false
syncB3d()
toast('El libro 3D no está disponible en este dispositivo')
})
}
function render() {
observer?.disconnect()
const keep = lastShown()
@@ -2849,6 +3135,7 @@ async function viewReader(mangaId, chapterId, startPage) {
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
renderModeQuick()
box.dataset.dir = settings.dir
syncB3d()
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
syncDirUi()
$('#dir').hidden = !isPaged()
@@ -2898,6 +3185,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const st = stageEl()
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
box.classList.toggle('zoomed', zoom.s > 1)
if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y)
$('#zoomreset').hidden = zoom.s === 1
}
function clampPan() {
@@ -2930,7 +3218,7 @@ async function viewReader(mangaId, chapterId, startPage) {
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
const xr = cx / window.innerWidth
const center = xr > 0.3 && xr < 0.7
if (dbl && (center || zoom.s > 1)) {
if (dbl && !atEnd && (center || zoom.s > 1)) {
clearTimeout(uiTimer)
if (zoom.s > 1) resetZoom()
else zoomAt(2.5, ...local(cx, cy))
@@ -2943,15 +3231,24 @@ async function viewReader(mangaId, chapterId, startPage) {
try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null }
}
box.addEventListener('pointerdown', e => {
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
if (!isPaged() || (atEnd && !b3dWanted()) || e.target.closest('a,button,select,input')) return
// Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre.
if (e.isPrimary && pts.size) {
pts.clear()
tap = null
if (gesture?.type === 'turn') finishTurn()
gesture = null
}
if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página
if (e.pointerType === 'mouse' && e.button !== 0) return
if (settings.sfx !== 'off') sfxUnlock()
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pts.size === 1) {
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
tap = { x: e.clientX, y: e.clientY, t: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
if (gesture) box.setPointerCapture?.(e.pointerId)
} else if (pts.size === 2 && atEnd) {
tap = null // la contraportada no se amplía
} else if (pts.size === 2) {
const [a, b] = [...pts.values()]
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
@@ -2976,14 +3273,14 @@ async function viewReader(mangaId, chapterId, startPage) {
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) {
const fwd = (dx < 0) === (settings.dir === 'ltr')
if (fwd ? shown.at(-1) + 1 < total : page > 0) {
const prev = [...shown]
if (fwd ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) {
const prev = [...shown], wasEnd = atEnd
finishTurn()
wantDrag = true
grabY = tap.y
try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null }
if (turning?.move) {
gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
gesture = { type: 'turn', ctl: turning, prev, wasEnd, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
turning.move(gesture.d / turning.range)
box.setPointerCapture?.(e.pointerId)
}
@@ -3024,6 +3321,9 @@ async function viewReader(mangaId, chapterId, startPage) {
page = g.prev[0]
setPageLabel()
scheduleSave()
if (g.wasEnd) return showEnd() // se quedó en la contraportada
atEnd = false
if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación
})
return
}
@@ -3031,11 +3331,11 @@ async function viewReader(mangaId, chapterId, startPage) {
const [p] = [...pts.values()]
gesture = { type: 'pan', x: p.x, y: p.y }
} else if (pts.size === 0) {
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
if (e.type === 'pointerup' && tap && !tap.moved && e.timeStamp - tap.t < 350) onTap(e.clientX, e.clientY)
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
}
gesture = null
tap = null
@@ -3056,7 +3356,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#zoomreset').addEventListener('click', resetZoom)
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
const saveGlobal = () => store.set('readerSettings', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d })
const savePrefs = () => {
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
@@ -3083,26 +3383,42 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
$('#hdone')?.addEventListener('click', async e => {
e.target.closest('button').disabled = true
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
})
if ($('#tomefx')) {
$('#tomefx').checked = settings.tomes !== false
$('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
}
// Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte.
const inSeq = c => !!book || !!tome?.has(c)
$('#chap').addEventListener('change', e => {
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
const c = chapters.findIndex(x => x.id === e.target.value)
if (seq && inSeq(c)) goTo(seq.chapterStart(c))
else location.hash = readUrl(mangaId, e.target.value)
})
if (book) {
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
if (seq) {
$('#bnext').addEventListener('click', e => {
e.preventDefault()
if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1))
else location.hash = readUrl(mangaId, chapters[curChap + 1].id)
})
$('#bprev').addEventListener('click', e => {
e.preventDefault()
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
let c = curChap
while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
goTo(book.chapterStart(c))
while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c--
if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c))
else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0)
else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id)
else goTo(seq.chapterStart(c))
})
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
}
if (book) {
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) }
}
$('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button')
@@ -3127,11 +3443,13 @@ async function viewReader(mangaId, chapterId, startPage) {
crop.ov.remove()
window.removeEventListener('scroll', crop.draw)
crop = null
reader.classList.remove('b3dcrop')
$('#clipbtn')?.classList.remove('on')
}
function startCrop() {
if (crop) return endCrop()
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
reader.classList.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal
reader.classList.remove('hide-ui')
$('.rpanel', reader).hidden = true
const ov = document.createElement('div')
@@ -3284,7 +3602,8 @@ async function viewReader(mangaId, chapterId, startPage) {
})
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
if (!blob) throw new Error('La zona es demasiado grande para este dispositivo')
const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
const at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter
const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), cover: manga.cover || '' })
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
@@ -3305,6 +3624,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
else if (e.key === backKey) backward()
else if (atEnd) return
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
else if (e.key === '0') resetZoom()
@@ -3340,9 +3660,22 @@ async function viewReader(mangaId, chapterId, startPage) {
})
}
}
if ($('#b3dfx')) {
$('#b3dfx').checked = !!settings.book3d
$('#b3dfx').addEventListener('change', e => {
settings.book3d = e.target.checked
saveGlobal()
// iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar.
if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {})
render()
})
}
render()
cleanup = () => {
b3d?.destroy()
b3d = null
book?.release()
clearInterval(tomeTimer)
freeLeafGL()
endCrop()
if (saveTimer) saveNow()
@@ -3877,6 +4210,24 @@ async function viewStats() {
}
// ---------- Ajustes ----------
// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura.
async function tableJpeg(file) {
const url = URL.createObjectURL(file)
try {
const im = new Image()
await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url })
const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight))
const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) })
const g = c.getContext('2d')
g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa
g.fillRect(0, 0, c.width, c.height)
g.imageSmoothingQuality = 'high'
g.drawImage(im, 0, 0, c.width, c.height)
const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88))
if (!blob) throw new Error('No se pudo preparar la imagen')
return blob
} finally { URL.revokeObjectURL(url) }
}
function viewSettings() {
app.innerHTML = `<h1>Ajustes</h1>
<div class="settings">
@@ -3887,6 +4238,13 @@ function viewSettings() {
<section><b>${icon('book')} Qué mostrar</b>
<p class="muted" style="margin:0;font-size:.88rem">Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.</p>
<select id="contentmode">${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `<option value="${k}" ${k === contentMode() ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
<section><b>${icon('book')} Libro 3D: fondo de la mesa</b>
<p class="muted" style="margin:0;font-size:.88rem">Pon cualquier imagen en lugar de la madera. Se guarda en tu cuenta y se usa en todos tus dispositivos.</p>
<img id="tableprev" class="tableprev" alt="Fondo actual de la mesa" hidden>
<div class="row" style="flex-wrap:wrap;gap:8px">
<label class="btn small">${icon('image')} Elegir imagen<input type="file" id="tablefile" accept="image/*" hidden></label>
<button class="btn small danger" id="tabledel" hidden>${icon('trash')} Volver a la madera</button>
</div></section>
<section><b>Cambiar contraseña</b>
<form id="pw" style="display:grid;gap:8px">
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
@@ -3921,6 +4279,37 @@ function viewSettings() {
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
</form></section>
</div>`
const showTable = () => {
const prev = $('#tableprev')
if (!prev) return
prev.hidden = !me.tableV
if (me.tableV) prev.src = `/api/table?v=${me.tableV}`
else prev.removeAttribute('src')
$('#tabledel').hidden = !me.tableV
}
showTable()
$('#tablefile').addEventListener('change', async e => {
const f = e.target.files[0]
e.target.value = ''
if (!f) return
try {
const blob = await tableJpeg(f)
const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
me.tableV = data.tableV
showTable()
toast('Fondo guardado')
} catch (err) { toast(err.message) }
})
$('#tabledel').addEventListener('click', async () => {
try {
await api('/api/table', { method: 'DELETE' })
me.tableV = 0
showTable()
toast('La mesa vuelve a ser de madera')
} catch (err) { toast(err.message) }
})
api('/api/notify').then(n => {
const f = $('#notify')
if (!f) return