Files
manga-reader/app/public/app.js
T

4586 lines
263 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const app = document.getElementById('app')
// Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto.
const ICONS = {
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/>',
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>',
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
settings: '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
play: '<path d="M7 4v16l13-8z"/>',
book: '<path d="M2 5h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 5h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
stopRead: '<circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/>',
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
trash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 15h10l1-15"/><path d="M10 11v6M14 11v6"/>',
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
back: '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
prev: '<path d="m15 18-6-6 6-6"/>',
next: '<path d="m9 18 6-6-6-6"/>',
more: '<path d="m6 9 6 6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
userPlus: '<circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0"/><path d="M19 8v6M16 11h6"/>',
flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
wifiOff: '<path d="M2 2l20 20"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 5.2-2.8"/><path d="M14.4 10.2A10 10 0 0 1 19 12.9"/><path d="M2 8.8a15 15 0 0 1 4.2-2.7"/><path d="M10.7 5A15 15 0 0 1 22 8.8"/><path d="M12 20h.01"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
bellOff: '<path d="M8.7 3A6 6 0 0 1 18 8a21 21 0 0 0 .6 5"/><path d="M17 17H3s3-2 3-9a4.7 4.7 0 0 1 .3-2"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="m2 2 20 20"/>',
chart: '<path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/>',
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>',
dots: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
shelf: '<path d="M3 3v18M21 3v18M3 12h18M3 21h18"/><path d="M6 12V6M9 12V5M12.5 12l1.6-6"/><path d="M6 21v-5M9 21v-6M15 21v-6M18 21v-4"/>',
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
}
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
// Iconos del menú superior (index.html).
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)', ws: 'Wikisource (libros en español)', ub: 'Mis libros (EPUB subidos)' }
const NO_CATS = new Set(['mi', 'gb', 'ws', 'ub']) // fuentes sin listado por categorías
const BOOK_SOURCES = new Set(['gb', 'ws', 'ub'])
const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
// Ajuste del usuario: ver solo manga, solo libros o ambos. Solo filtra lo que se ofrece; un enlace directo sigue abriendo.
const contentMode = () => me?.content_mode || 'both'
const wantsId = id => contentMode() === 'both' || (contentMode() === 'books') === isBookId(id)
const visibleSources = () => Object.entries(SOURCES).filter(([k]) => contentMode() === 'both' || (contentMode() === 'books') === BOOK_SOURCES.has(k))
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
const FILTERS = {
none: ['Sin filtro', ''],
sepia: ['Sepia', 'sepia(.65) contrast(.95)'],
paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'],
vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'],
night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'],
gray: ['Blanco y negro', 'grayscale(1)'],
contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'],
vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'],
// Sin filtro CSS: una capa ámbar en modo multiplicar sobre todo el lector (.reader.bluelight), que
// quita azul a todo lo que hay en pantalla, también durante las animaciones.
blue: ['Filtro de luz azul', ''],
}
const DL_ACTIVE = ['queued', 'downloading']
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min`
const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null }
const fmtDate = d => {
const t = new Date(`${String(d).slice(0, 10)}T12:00:00`)
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' })
}
const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`)
// Progreso global contando la fracción del capítulo en curso.
function downloadPct(d) {
if (!d?.total) return 0
const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0
return Math.min(100, ((d.done + frac) / d.total) * 100)
}
function downloadDetail(d) {
const c = d?.current
if (!c) return d?.status === 'queued' ? 'Esperando turno…' : ''
return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…`
}
function downloadLabel(d) {
if (!d) return ''
const n = `${d.done}/${d.total || '?'}`
return {
queued: 'En cola…',
downloading: `Descargando ${n}`,
done: `Descargado (${d.done} caps.)`,
error: `Descarga incompleta ${n}`,
cancelled: `Descarga cancelada ${n}`,
}[d.status] || d.status
}
const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' }
let me = null
let cleanup = null
// ---------- Utilidades ----------
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const $ = (sel, root = document) => root.querySelector(sel)
// Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML).
document.addEventListener('error', e => {
const img = e.target
if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return
img.dataset.failed = '1'
img.src = '/icon.svg'
}, true)
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} },
}
function toast(msg) {
const t = document.getElementById('toast')
t.textContent = msg
t.hidden = false
clearTimeout(toast.t)
toast.t = setTimeout(() => (t.hidden = true), 2500)
}
const NO_SERVER = 'Sin conexión con el servidor'
async function api(path, opts = {}) {
// Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un
// servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red.
const ctrl = new AbortController()
const watchdog = setTimeout(async () => { if (!(await pingServer())) ctrl.abort() }, 2500)
let res
try {
res = await fetch(path, {
...opts,
signal: ctrl.signal,
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
body: opts.body ? JSON.stringify(opts.body) : undefined,
})
} catch {
setServerReachable(false)
throw new Error(NO_SERVER)
} finally {
clearTimeout(watchdog)
}
// Respuesta de la copia del service worker: el servidor no contestó a tiempo; comprobar el estado.
if (res.headers.get('x-from-cache')) refreshNet()
else if (!serverReachable) setServerReachable(true)
const data = await res.json().catch(() => ({}))
if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') }
// 503: el servidor ya sabe que no hay internet. 502: una fuente falló; el servidor tarda unos segundos en confirmarlo.
if (res.status === 503) refreshNet()
else if (res.status === 502) setTimeout(refreshNet, 7000)
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
return data
}
// ---------- Estado de la conexión ----------
// Dos casos distintos, con su aviso arriba:
// - serverReachable = false: no se llega al servidor (sin red, servidor caído). Se lee lo guardado en
// este dispositivo; la portada lo muestra. Al volver, se envía el progreso pendiente.
// - serverOnline = false: el servidor contesta pero él no tiene internet. Se lee lo descargado en el
// servidor; la portada lo muestra.
// Se comprueba cada 20 s (cada 10 s sin servidor) y al volver a la pestaña.
let serverOnline = true
let serverReachable = true
let netChecked = false
const isHome = () => !(location.hash.slice(1).split('?')[0] || '/').split('/').filter(Boolean).length
async function pingServer() {
const r = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) }).catch(() => null)
return r ? (r.ok ? await r.json().catch(() => ({ online: true })) : { online: true }) : null
}
function netBanner() {
let b = $('#netbanner')
const msg = !serverReachable
? `${icon('wifiOff')}<span><b>${NO_SERVER}.</b> ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.</span>`
: !serverOnline ? `${icon('wifiOff')}<span><b>El servidor no tiene internet.</b> Puedes leer lo descargado en el servidor; se reconecta solo.</span>` : ''
if (!msg) return b?.remove()
if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) }
if (b.innerHTML !== msg) b.innerHTML = msg
}
function setServerReachable(ok) {
if (ok === serverReachable) return
serverReachable = ok
netBanner()
if (ok) { flushProgress(); refreshNet() }
if (isHome()) router()
}
async function refreshNet() {
const st = await pingServer()
netChecked = true
if (!st) return setServerReachable(false)
const changed = !serverReachable || st.online !== serverOnline
serverOnline = st.online !== false
if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar
netBanner()
if (changed && isHome()) router()
}
setInterval(() => { if (serverReachable) refreshNet() }, 20e3)
setInterval(() => { if (!serverReachable) refreshNet() }, 10e3)
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() })
function timeAgo(ts) {
const s = (Date.now() - ts) / 1000
if (s < 60) return 'hace un momento'
if (s < 3600) return `hace ${Math.floor(s / 60)} min`
if (s < 86400) return `hace ${Math.floor(s / 3600)} h`
return `hace ${Math.floor(s / 86400)} d`
}
const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '')
const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}`
// ---------- Sin conexión (capítulos guardados en este dispositivo) ----------
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
const OFF_CACHE = 'offline-v1'
// Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL.
if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {})
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
const offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
// Capítulos guardados agrupados por manga.
function offByManga() {
const g = new Map()
for (const [chapterId, c] of Object.entries(offIdx())) {
const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 }
m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at)
g.set(c.mangaId, m)
}
return [...g.values()].sort((a, b) => b.at - a.at)
}
// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }]
function offEnqueue(items) {
for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it)
navigator.storage?.persist?.().catch(() => {})
offEmit()
offRun()
}
async function offRun() {
if (offState.running) return
while (offState.queue.length) {
const it = offState.queue.shift()
offState.running = { ...it, done: 0, total: 0 }
offEmit()
try { await offSaveChapter(it) } catch (err) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) }
offState.cancelled.delete(it.chapterId)
}
offState.running = null
offEmit()
}
// Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en
// curso se interrumpe y se borra lo que ya se había guardado de él.
function offCancel(mangaId = null) {
offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId)
if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) {
offState.cancelled.add(offState.running.chapterId)
offState.abort?.abort() // corta las páginas que se estén bajando
}
offEmit()
}
async function offSaveChapter(it) {
const cache = await caches.open(OFF_CACHE)
const saved = []
offState.abort = new AbortController()
try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) {
await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias
throw err
}
}
async function offSaveInto(cache, it, saved, signal) {
const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') }
const hd = store.get('readerSettings', {}).hd !== false
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
const res = await fetch(chapUrl, { signal })
if (!res.ok) throw new Error(`Error ${res.status}`)
const data = await res.clone().json()
if (data.text) {
// Libro: lo que se guarda es su texto y sus imágenes; la lista queda en la copia, para poder borrarlos después.
const textUrl = `/api/book/${it.mangaId}/text`
const tres = await fetch(textUrl, { signal })
if (!tres.ok) throw new Error(`Error ${tres.status}`)
const text = await tres.json()
const images = new Set(text.chapters.flatMap(c => c.blocks).filter(b => b.k === 'img').map(b => `/api/book/${it.mangaId}/img/${b.n}`))
data.pages = [textUrl, ...images]
await cache.put(chapUrl, new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }))
} else await cache.put(chapUrl, res)
saved.push(chapUrl)
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
const mres = await fetch(mUrl).catch(() => null)
if (mres?.ok) await cache.put(mUrl, mres)
if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) }
offState.running.total = data.pages.length
let bytes = 0, i = 0
const worker = async () => {
while (i < data.pages.length) {
stop()
const u = data.pages[i++]
// Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite.
let blob, type
for (let t = 1; ; t++) {
try {
const r = await fetch(u, { signal: AbortSignal.any([signal, AbortSignal.timeout(90e3)]) })
if (!r.ok) throw new Error(`página ${r.status}`)
type = r.headers.get('Content-Type')
blob = await r.blob()
break
} catch (err) {
stop()
if (t >= 3) throw err
await new Promise(res => setTimeout(res, 2000 * t))
}
}
bytes += blob.size
stop()
await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } }))
saved.push(u)
offState.running.done++
offEmit()
}
}
await Promise.all([worker(), worker(), worker()])
stop()
const idx = offIdx()
idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() }
store.set('offlineChapters', idx)
}
async function offRemove(chapterIds) {
const cache = await caches.open(OFF_CACHE)
const idx = offIdx()
for (const id of chapterIds) {
const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true })
if (hit) {
const data = await hit.json().catch(() => ({ pages: [] }))
await Promise.all((data.pages || []).map(u => cache.delete(u)))
await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true })
}
const mangaId = idx[id]?.mangaId
delete idx[id]
if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true })
}
store.set('offlineChapters', idx)
offEmit()
}
// Progreso leído sin conexión: se guarda aquí y se envía al volver la red.
function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) }
async function flushProgress() {
const q = store.get('pendingProgress', {})
for (const [k, p] of Object.entries(q)) {
try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo
}
store.set('pendingProgress', q)
}
window.addEventListener('online', flushProgress)
setTimeout(flushProgress, 3000)
// Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes).
let installPrompt = null
window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e })
// ---------- 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 }
document.body.classList.remove('reading')
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) {
const [r] = await Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()])
me = r.user
me && (me.signup = r.signup)
if (!me) return viewAuth(r.signup)
}
try {
if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'info') return (location.hash = `#/manga/${parts[1]}`) // la ficha ya incluye la info
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(params)
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings()
return await viewHome(params)
} catch (e) {
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
}
}
window.addEventListener('hashchange', router)
router()
// ---------- Login / registro ----------
function viewAuth(signup) {
app.innerHTML = `
<div class="auth">
<h1>Manga Reader</h1>
<p class="muted">Inicia sesión para leer y guardar tu progreso.</p>
<form>
<input type="text" name="username" placeholder="Usuario" autocomplete="username" required>
<input type="password" name="password" placeholder="Contraseña" autocomplete="current-password" required>
<p class="error"></p>
<button class="btn primary" value="login">${icon('login')} Entrar</button>
${signup ? `<button class="btn" value="register">${icon('userPlus')} Crear cuenta</button>` : ''}
</form>
</div>`
const form = $('form', app)
form.addEventListener('submit', async ev => {
ev.preventDefault()
const mode = ev.submitter?.value === 'register' ? 'register' : 'login'
const fd = new FormData(form)
try {
await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } })
me = null
location.hash = '#/'
router()
} catch (e) { $('.error', form).textContent = e.message }
})
}
// ---------- Inicio ----------
function mangaCard(m) {
return `<a class="card" href="#/manga/${m.id}">
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
}
function continueCard(h) {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
// Terminado con capítulos nuevos: a la ficha, para elegir el siguiente.
return `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}">
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy" data-fallback>
<div class="info"><b>${esc(h.manga_title)}</b>
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''}
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
}
// Sección "Novedades": mangas seguidos con capítulos nuevos.
function newsSection(fol) {
fol = { ...fol, follows: fol.follows.filter(f => wantsId(f.manga_id)) }
const fresh = fol.follows.filter(f => f.newCount > 0)
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
if (!fol.follows.length) return ''
return `<div class="row" style="justify-content:space-between;margin-top:8px">
<h2 style="margin:12px 0">${icon('sparkles')} Novedades ${fresh.length ? `<span class="badge">${fresh.reduce((n, f) => n + f.newCount, 0)}</span>` : ''}</h2>
<span class="row muted" style="font-size:.85rem">Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
<button class="btn small" id="checknow" ${fol.checking ? 'disabled' : ''}>${icon('refresh')} Comprobar ahora</button></span></div>
${fresh.length ? `<div class="continue">${fresh.map(f => `<a class="cont" href="#/manga/${f.manga_id}">
<img src="${esc(f.cover || '/icon.svg')}" alt="" loading="lazy">
<div class="info"><b>${esc(f.title)}</b>
<div><span class="badge">${f.newCount} ${f.newCount === 1 ? 'cap. nuevo' : 'caps. nuevos'}</span></div>
<div class="muted" style="font-size:.8rem">${f.last_new_at ? timeAgo(f.last_new_at) : ''}</div></div></a>`).join('')}</div>`
: '<p class="muted" style="margin-top:0">No hay capítulos nuevos en los mangas que sigues.</p>'}`
}
function bindNews() {
$('#checknow')?.addEventListener('click', async e => {
e.target.closest('button').disabled = true
await api('/api/follows/check', { method: 'POST' })
toast('Comprobando capítulos nuevos…')
// Refrescar la sección mientras dura la comprobación.
const poll = async () => {
const fol = await api('/api/follows').catch(() => null)
if (!fol || !$('#news')) return
$('#news').innerHTML = newsSection(fol)
bindNews()
if (fol.checking) setTimeout(poll, 3000)
else toast('Comprobación terminada')
}
setTimeout(poll, 2000)
})
}
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h).
async function loadRecs(refresh = false) {
if (!$('#recs')) return
const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null)
// La portada pudo redibujarse mientras tanto: usar la sección actual.
const box = $('#recs')
if (!box) return
if (!r?.items?.length) { box.innerHTML = ''; return }
box.innerHTML = `<div class="row rhead"><h2>${icon('sparkles')} Recomendado para ti</h2>
<span class="muted small">Según lo que más lees: ${r.genres.map(g => `<a href="${tagHref(g.source || 'md', g)}">${esc(g.name)}</a>`).join(', ')}</span>
<button class="btn small" id="recref" title="Otras recomendaciones">${icon('refresh')}</button></div>
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) })
}
// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local.
async function viewHomeOffline(params) {
app.innerHTML = `
<section id="cont"></section>
<h2>${icon('download')} Descargado en el servidor</h2>
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
<div id="results" class="loading">Cargando…</div>`
const [all, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
const list = all.filter(m => wantsId(m.id))
const have = new Set(list.map(m => m.id))
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
const res = $('#results')
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
const render = () => {
const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean)
const shown = list.filter(m => words.every(w => fold(m.title).includes(w)))
res.className = shown.length ? 'grid' : 'loading'
res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.'
}
$('form.search').addEventListener('submit', e => e.preventDefault())
$('[name=q]').addEventListener('input', render)
render()
}
// Portada sin conexión con el servidor: lo guardado en este dispositivo.
function viewHomeDevice() {
const dev = OFFLINE_OK ? offByManga() : []
app.innerHTML = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
? `<div class="grid">${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}</div>`
: `<p class="muted">${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.'
: 'Leer sin conexión necesita abrir la app por HTTPS.'}</p>`)
}
async function viewHome(params) {
if (!serverReachable) return viewHomeDevice()
if (!serverOnline) return viewHomeOffline(params)
const q = params.get('q') || ''
const tag = params.get('tag') || ''
const tagName = params.get('tagName') || tag
const anyLang = params.get('any') === '1'
const shownSources = visibleSources()
const isShown = k => shownSources.some(([s]) => s === k)
const source = isShown(params.get('source')) ? params.get('source') : isShown(store.get('source', 'lc')) ? store.get('source', 'lc') : shownSources[0][0]
let mode = tag ? 'cats' : params.get('mode') || 'popular'
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
store.set('source', source)
const showCats = mode === 'cats' && !tag
app.innerHTML = `
<section id="cont"></section>
<section id="recs"></section>
<div class="tabs" id="sources">${shownSources.map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
${source === 'ub' ? `<div class="row" style="margin:10px 0;flex-wrap:wrap;gap:10px"><label class="btn primary" id="ubpick">${icon('plus')} Subir EPUB<input type="file" id="ubfile" accept=".epub,application/epub+zip" multiple hidden></label><span class="muted" style="font-size:.85rem">Solo tú puedes verlos. Máximo 100 MB por libro.</span></div>` : ''}
${source === 'ub' ? (q ? `<h2>Resultados para “${esc(q)}”</h2>` : '') : q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} ${BOOK_SOURCES.has(source) ? 'Añadidos recientemente' : 'Actualizados recientemente'}</button>
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
<div id="results" class="loading">Cargando…</div>
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>${icon('more')} Cargar más</button></div>`
const form = $('form', app)
const go = (extra = {}) => {
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
location.hash = `#/?${p}`
}
form.addEventListener('submit', e => { e.preventDefault(); go() })
$('[name=any]').addEventListener('change', () => go())
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` }))
if (!q && !tag && !showCats) {
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
if (!$('#cont')) return
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished && wantsId(h.manga_id)), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
bindNews(fol)
}).catch(() => {})
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
}
$('#ubfile')?.addEventListener('change', async e => {
const files = [...e.target.files]
const label = $('#ubpick')
label.classList.add('disabled')
let added = 0
for (const [i, f] of files.entries()) {
toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`)
try {
const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f })
const d = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(d.error || `Error ${r.status}`)
added++
} catch (err) { toast(`${f.name}: ${err.message}`) }
}
label.classList.remove('disabled')
if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() }
})
const res = $('#results')
if (showCats) {
const cats = await api(`/api/categories?source=${source}`)
const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map())
res.className = 'cats'
res.innerHTML = [...groups].map(([g, list]) => `<h3>${esc(g)}</h3><div class="tags">${list.map(t => `<a class="tag big" href="${tagHref(source, t)}">${esc(t.name)}</a>`).join('')}</div>`).join('')
return
}
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
const pageable = source === 'md' || source === 'gb' || source === 'ws' || !!tag || (source === 'mi' && mode === 'latest' && !q)
const seen = new Set()
let page = 1
const load = async () => {
const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) })
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
if (page === 1) {
res.className = list.length ? 'grid' : 'loading'
res.innerHTML = list.length ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : 'Sin resultados.'
}
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
$('#more').hidden = !pageable || list.length < 20
}
$('#more').addEventListener('click', async e => {
e.target.disabled = true
page++
try { await load() } catch (err) { toast(err.message) }
e.target.disabled = false
})
await load()
}
// ---------- Ficha del manga ----------
// ---------- Datos de la obra de AniList y MangaUpdates (parte de la ficha) ----------
const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' }
const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' }
const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' }
const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo')
const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' }
const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' }
const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d))
// Lo que la ficha pinta a partir de la información guardada de la obra (d.found).
function infoParts(d, manga) {
const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : '')
const t = d.titles
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
const cardOf = r => `<a class="card" href="#/?q=${encodeURIComponent(r.title)}" title="Buscar «${esc(r.title)}»">
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
const seen = new Set([manga.title.toLowerCase()])
const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()))
const srcDesc = (manga.description || '').trim()
return {
banner: d.banner,
sub: altTitles.join(' · '),
scores: `<div class="scores">
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
</div>
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}`,
kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))}
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
${row('Publicación', esc(dates || manga.year || ''))}
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))}
${row('Extensión', esc(size))}
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}`,
genres: d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join(''),
softTags: d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join(''),
synopsis: `${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}`,
extra: `${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${t.other.map(esc).join(', ')}</p>` : ''}
${d.related.length ? `<h2>Obras relacionadas</h2><div class="hrow">${d.related.map(cardOf).join('')}</div>` : ''}
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="hrow">${d.recommendations.map(cardOf).join('')}</div>` : ''}
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>`,
}
}
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, 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
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
const isBook = isBookId(id)
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
const first = chapters[0]
let mainBtn = ''
if (progress && chapters.some(c => c.id === progress.chapter_id)) {
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}</a>`
} else if (progress) {
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}</a>`
}
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [
manga.source === 'lc' || isBook ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' || isBook ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
manga.source === 'ub' ? `<button id="ubdel" class="btn danger">${icon('trash')} Eliminar libro</button>` : '',
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('')
const srcTags = manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')
const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}`
app.innerHTML = `
<div class="banner" id="mbanner" hidden></div>
<div class="detail">
<img class="cover" id="mcover" src="${esc(manga.cover || '/icon.svg')}" alt="">
<div class="detail-body">
<h1>${esc(manga.title)}</h1>
<div class="muted" id="msub">${srcSub}</div>
<div id="mscores"></div>
<div class="actions">${mainBtn}
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
${SHELF_OK && !isBook && !/^(manhwa|manhua|webtoon)$/i.test(manga.type || '') ? `<a class="btn" id="toshelf" href="#/shelf?focus=${encodeURIComponent(id)}" title="Ver la obra en el estante 3D">${icon('shelf')} Estante</a>` : ''}
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="info-block" id="msyn"></div>
<div class="tags" id="mtags"></div>
</div>
</div>
<div class="info-block" id="mextra"></div>
<div class="drawer-back" id="libback" hidden></div>
<aside class="drawer" id="libdrawer" aria-label="Más opciones" inert>
<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}
</div>
<div class="libpanel" ${isBook && !OFFLINE_OK ? 'style="display:none"' : ''}><div class="psec" id="dl" ${isBook ? 'style="display:none"' : ''}></div><div class="psec" id="hd" hidden ${isBook ? 'style="display:none"' : ''}></div><div class="psec" id="off" hidden></div></div>
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
</aside>
<div class="list-head">
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
<div class="row">
${isLc ? '' : `<select id="lang" title="Idioma de los capítulos">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>`}
${chapters.length > 1 ? `<button class="btn small" id="order">${icon('sort')} Invertir orden</button>` : ''}
</div>
</div>
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a buscarlo en <a class="tag" href="${altSearch}">LeerCapitulo</a> o <a class="tag" href="${altSearchMi}">M440</a>.
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
<div id="selbar" hidden></div>`
// --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar.
const synBox = $('#msyn')
const paintInfo = d => {
if (!synBox.isConnected) return // ya se cambió de página
const parts = d?.found ? infoParts(d, manga) : null
const banner = $('#mbanner')
// El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada.
const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner
banner.hidden = !bannerUrl
banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : ''
if (!manga.cover && d?.cover) $('#mcover').src = d.cover
$('#msub').innerHTML = parts ? esc(parts.sub) : srcSub
$('#mscores').innerHTML = parts?.scores || ''
const folder = download ? `<dt>Carpeta</dt><dd>mangas/${esc(download.folder)}</dd>` : ''
$('#mkv').innerHTML = (parts?.kv || '') + folder
$('#mdata').hidden = !parts && !folder
$('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '')
const srcDesc = (manga.description || '').trim()
synBox.innerHTML = parts?.synopsis || (srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : '')
$('#mextra').innerHTML = parts?.extra || (d ? `<p class="muted note">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
}
paintInfo(savedInfo)
if (!savedInfo && !isBook) {
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
}
// --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra.
const openLib = open => {
$('#libdrawer').classList.toggle('open', open)
$('#libdrawer').inert = !open
$('#libback').hidden = !open
}
// El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo).
const libBusy = {}
const markLib = (kind, busy) => {
libBusy[kind] = busy
$('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' <span class="libbusy" title="En curso"></span>' : ''}`
}
const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' })
$('#libbtn').addEventListener('click', () => openLib(true))
$('#libclose').addEventListener('click', () => openLib(false))
$('#libback').addEventListener('click', () => openLib(false))
$('#tochap')?.addEventListener('click', toChapters)
const onLibKey = e => { if (e.key === 'Escape') openLib(false) }
document.addEventListener('keydown', onLibKey)
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
let wantedSet = new Set(download?.wanted || [])
let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
let hdInfo = upscale
let sel = null // null | { kind: 'dl' | 'hd', mode: 'pending' | 'range' | 'pick' }
const picked = new Set()
let lastPick = null
const KINDS = {
dl: {
verb: 'Descargar', ic: 'download', modes: { range: 'Rango', pick: 'Elegir' },
// Sin descargar y sin estar ya en cola.
candidates: () => chapters.filter(c => !dlSet.has(c.id) && !wantedSet.has(c.id)),
rangeBase: () => chapters,
mark: c => (dlSet.has(c.id) ? ' (descargado)' : ''),
summary: ids => {
const pages = ids.reduce((n, cid) => n + (chapters.find(c => c.id === cid)?.pages || 0), 0)
return plural(ids.length, 'capítulo') + (pages ? ` · ${pages} págs.` : '')
},
offTitle: c => (dlSet.has(c.id) ? 'Ya descargado' : 'Ya está en cola'),
run: async ids => {
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, chapterIds: ids } }))
toast(`${plural(ids.length, 'capítulo')} en cola de descarga`)
},
},
hd: {
verb: 'Reescalar', ic: 'sparkles', modes: { pending: 'Pendientes', range: 'Rango', pick: 'Elegir' },
// Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar).
candidates: () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')),
rangeBase: () => chapters.filter(c => dlSet.has(c.id)),
mark: c => (hdSet.has(c.id) ? ' (HD)' : ''),
summary: ids => {
const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
return `${plural(ids.length, 'capítulo')} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
},
offTitle: c => (!dlSet.has(c.id) ? 'No está descargado' : 'Ya está en HD o en cola'),
run: async ids => {
renderHd(await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }))
toast(`${plural(ids.length, 'capítulo')} en cola para HD`)
},
},
}
if (OFFLINE_OK) KINDS.off = {
verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' },
candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id),
rangeBase: () => chapters,
mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''),
summary: ids => plural(ids.length, 'capítulo'),
offTitle: () => 'Ya está en el dispositivo',
run: async ids => {
offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } }))
toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`)
},
}
const selIds = () => {
if (!sel) return []
const K = KINDS[sel.kind]
const cand = K.candidates()
if (sel.mode === 'pending') return cand.map(c => c.id)
if (sel.mode === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
const base = K.rangeBase()
let a = base.findIndex(c => c.id === $('#selfrom')?.value), b = base.findIndex(c => c.id === $('#selto')?.value)
if (a < 0 || b < 0) return []
if (a > b) [a, b] = [b, a]
const inRange = new Set(base.slice(a, b + 1).map(c => c.id))
return cand.filter(c => inRange.has(c.id)).map(c => c.id)
}
const updateSummary = () => {
const ids = selIds()
const K = sel && KINDS[sel.kind]
if ($('#selsum')) $('#selsum').textContent = ids.length ? K.summary(ids) : sel?.mode === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
if ($('#selgo')) $('#selgo').disabled = !ids.length
const bar = $('#selbar')
if (!bar) return
bar.hidden = sel?.mode !== 'pick'
if (sel?.mode !== 'pick') return
bar.innerHTML = `<span>${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}</span>
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon(K.ic)} ${K.verb}</button>
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
$('#selrun').addEventListener('click', runSel)
$('#selexit').addEventListener('click', () => setSel(null))
}
async function runSel() {
const ids = selIds()
if (!ids.length) return
const K = KINDS[sel.kind]
try {
if (sel.kind === 'hd' && sel.mode === 'pending' && $('#hdautoform') && $('#hdautoform').checked !== !!hdInfo?.auto) {
await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
}
picked.clear()
setSel(null)
await K.run(ids)
} catch (err) { toast(err.message) }
}
function setSel(next) {
const wasPick = sel?.mode === 'pick', prevKind = sel?.kind
if (next?.kind !== prevKind) picked.clear()
sel = next
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
if (next) renderSelForm(next.kind)
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
updateSummary()
}
function renderSelForm(kind) {
const box = $(`#${kind}form`)
if (!box) return
const open = sel?.kind === kind
box.hidden = !open
if (!open) { box.innerHTML = ''; return }
const K = KINDS[kind]
const base = K.rangeBase(), cand = K.candidates()
const opt = (c, on) => `<option value="${c.id}" ${on ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${K.mark(c)}</option>`
const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
box.innerHTML = `
<div class="seg" role="tablist">${Object.entries(K.modes).map(([m, label]) => `<button data-sm="${m}" class="${sel.mode === m ? 'on' : ''}">${label}</button>`).join('')}</div>
${sel.mode === 'range' ? `<div class="row range"><label>Desde <select id="selfrom">${base.map(c => opt(c, c === from)).join('')}</select></label>
<label>hasta <select id="selto">${base.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
${sel.mode === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
${sel.mode === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
<div class="row"><b id="selsum" class="sum"></b></div>
<div class="psec-actions"><button class="btn small primary" id="selgo">${icon(K.ic)} ${K.verb}</button><button class="btn small" id="selclose">Cerrar</button></div>`
box.querySelectorAll('[data-sm]').forEach(b => b.addEventListener('click', () => setSel({ kind, mode: b.dataset.sm })))
$('#selfrom')?.addEventListener('change', updateSummary)
$('#selto')?.addEventListener('change', updateSummary)
$('#selclose').addEventListener('click', () => setSel(null))
$('#selgo').addEventListener('click', runSel)
}
// Tras un sondeo: repintar lo que dependa de los conjuntos y cerrar el selector si ya no queda nada.
const refreshSel = () => {
renderList()
if (sel && sel.mode !== 'pick') renderSelForm(sel.kind)
if (sel && !KINDS[sel.kind].candidates().length) setSel(null)
else updateSummary()
}
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
let dlTimer = null
const renderDl = d => {
const box = $('#dl')
if (!box) return
const active = d && DL_ACTIVE.includes(d.status)
markLib('dl', !!active)
const some = d?.scope === 'some'
const partial = d && d.done < d.total
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
const pickBtn = label => (canPick ? `<button class="btn small ${sel?.kind === 'dl' ? 'on' : ''}" id="dlopen">${icon('download')} ${label}</button>` : '')
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
let status = '', body = '', actions = ''
if (!d) {
body = '<div class="note muted">Guarda capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD). Puedes bajar el manga entero o solo algunos capítulos.</div>'
actions = `<button class="btn small" id="dlall">${icon('download')} Descargar todo</button>${pickBtn('Elegir capítulos…')}`
} else if (active) {
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
actions = `${pickBtn('Añadir capítulos…')}<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
} else {
const have = dlSet.size
status = d.status === 'done'
? `${icon('check')} ${some ? `${have} de ${chapters.length}` : have} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}`
: downloadLabel(d)
body = bar + (d.status === 'error' && d.error ? `<div class="error note">${esc(d.error)}</div>` : '')
+ (some && d.status === 'done' ? '<div class="note muted">Solo capítulos elegidos: los nuevos no se descargan solos.</div>' : '')
const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null
actions = some
? `${resume ? `<button class="btn small" id="dlgo">${icon('refresh')} ${resume}</button>` : ''}${pickBtn('Descargar más…')}<button class="btn small" id="dlall" title="Bajar también todos los capítulos que falten y, en adelante, los nuevos">${icon('download')} Descargar todo</button>${delBtn}`
: `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(resume ? 'download' : 'refresh')} ${resume || 'Buscar capítulos nuevos'}</button>${delBtn}`
}
if (!$('#dltop')) box.innerHTML = '<div id="dltop" class="psec-grid"></div><div id="dlform" class="hdform" hidden></div>'
$('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
$('#dlgo')?.addEventListener('click', async () => {
// Reanudar respeta el modo (todo o capítulos elegidos).
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d)))
})
$('#dlall')?.addEventListener('click', async () => {
const missing = chapters.filter(c => !dlSet.has(c.id)).length
if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
toast(`Descargando en mangas/${manga.title}`)
})
$('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' }))
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
$('#dldel')?.addEventListener('click', async () => {
if (!confirm(d.shared ? `¿Quitar «${manga.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan para él y lo puedes seguir leyendo.`
: `¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return
const r = await api(`/api/download/${id}`, { method: 'DELETE' })
toast(r.kept ? 'Quitado de tu biblioteca' : 'Copia local eliminada')
viewManga(id, langOverride)
})
const wantedBefore = wantedSet.size
wantedSet = new Set(d?.wanted || [])
if (wantedSet.size !== wantedBefore) refreshSel()
clearTimeout(dlTimer)
if (active) dlTimer = setTimeout(async () => {
const next = await api(`/api/download/${id}`).catch(() => d)
if (!$('#dl')) return
if (next && next.done !== d.done) {
const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null)
fresh?.downloaded.forEach(x => dlSet.add(x))
if (fresh?.download) next.size = fresh.download.size
if (fresh?.upscale) renderHd(fresh.upscale)
refreshSel()
}
renderDl(next)
}, 2000)
}
// --- Reescalado HD de lo descargado.
let hdTimer = null
const renderHd = u => {
const box = $('#hd')
if (!box) return
clearTimeout(hdTimer)
hdInfo = u
const show = !!(u?.available && u.downloaded && !offline)
box.hidden = !show
markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error')))
if (show) {
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
const active = !!u.current || queued > 0
const c = u.current
const status = `${u.hd} de ${u.downloaded} capítulos${u.size ? ` · ${fmtSize(u.size)}` : ''}`
const remaining = Object.entries(u.jobs).filter(([, st]) => st !== 'error').map(([cid]) => cid)
let body = ''
if (active) {
const pct = c?.total ? (c.pages / c.total) * 100 : 0
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
<div class="note muted">${c ? `Cap. ${esc(c.num ?? '?')} · pág. ${c.pages}/${c.total}` : 'Esperando turno…'}${queued > 1 ? ` · ${queued - 1} más en cola` : ''} · faltan ${fmtDur(remaining.reduce((n, cid) => n + (u.pages?.[cid] || 0), 0) * u.secPerPage - (c?.pages || 0) * u.secPerPage)}</div>`
} else if (!u.hd) {
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
}
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
const canAdd = KINDS.hd.candidates().length > 0
const actions = [
canAdd ? `<button class="btn small ${sel?.kind === 'hd' ? 'on' : ''}" id="hdopen">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
].join('')
// Los formularios se conservan entre sondeos: solo se repinta la parte de arriba.
if (!$('#hdtop')) box.innerHTML = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
$('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' }))
$('#hdcancel')?.addEventListener('click', async () => {
if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return
renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' }))
})
$('#hddel')?.addEventListener('click', async () => {
if (!confirm(`¿Quitar la versión HD de «${manga.title}»?\nSe borra mangas/${download?.folder ?? ''}/.hd${u.size ? ` (${fmtSize(u.size)})` : ''}; los capítulos originales se quedan.`)) return
renderHd(await api(`/api/upscale/${id}`, { method: 'DELETE' }))
toast('Versión HD eliminada')
})
if (active) hdTimer = setTimeout(async () => { const next = await api(`/api/upscale/${id}`).catch(() => null); if (next && $('#hd')) renderHd(next) }, 3000)
}
const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs)
hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {}
if (changed) refreshSel()
}
// --- En este dispositivo (sin conexión).
const renderOff = () => {
const box = $('#off')
if (!box || !OFFLINE_OK) return
const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id)
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
const run = offState.running?.mangaId === id ? offState.running : null
const queued = offState.queue.filter(q => q.mangaId === id).length
markLib('off', !!(run || queued))
box.hidden = false
if (isBook) {
// Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes.
const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : ''
const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.'
box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}<div class="note muted">${note}</div><div class="psec-actions">
${mine.length ? `<button class="btn small danger" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : run || queued ? '' : `<button class="btn small" id="offbook">${icon('phone')} Guardar libro</button>`}</div>`
$('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }]))
$('#offdel')?.addEventListener('click', async () => {
if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return
await offRemove(mine.map(([cid]) => cid))
toast('Borrado del dispositivo')
})
return
}
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
let body = ''
if (run || queued) {
const pct = run?.total ? (run.done / run.total) * 100 : 0
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
<div class="note muted">${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}</div>`
} else if (!mine.length) body = '<div class="note muted">Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).</div>'
const actions = [
nextIds.length && !run ? `<button class="btn small" id="offnext">${icon('phone')} Guardar ${nextIds.length === 1 ? 'el siguiente' : `los ${nextIds.length} siguientes`}</button>` : '',
KINDS.off.candidates().length ? `<button class="btn small ${sel?.kind === 'off' ? 'on' : ''}" id="offopen">${icon('phone')} Elegir capítulos…</button>` : '',
mine.length ? `<button class="btn small danger push" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : '',
].join('')
if (!$('#offtop')) box.innerHTML = '<div id="offtop" class="psec-grid"></div><div id="offform" class="hdform" hidden></div>'
$('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `<div class="psec-actions">${actions}</div>`
$('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id)))
$('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' }))
$('#offdel')?.addEventListener('click', async () => {
if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return
await offRemove(mine.map(([cid]) => cid))
toast('Borrado del dispositivo')
})
}
let offRefreshAt = 0
const onOff = () => {
if (!$('#off')) return
renderOff()
// La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada).
const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0)
if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() }
}
if (OFFLINE_OK) offState.listeners.add(onOff)
// --- Listas propias (menú "Listas").
let listSet = new Set(inLists)
const renderListsBtn = () => {
const b = $('#lbtn')
if (!b) return
b.classList.toggle('on', listSet.size > 0)
b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}`
}
renderListsBtn()
async function renderListsMenu() {
const box = $('#lmlist')
if (!box) return
const lists = await api('/api/lists').catch(() => [])
box.innerHTML = lists.map(l => `<label class="check lcheck"><input type="checkbox" data-list="${l.id}" ${listSet.has(l.id) ? 'checked' : ''}> ${esc(l.name)}</label>`).join('')
+ `<button id="lnew">${icon('plus')} Nueva lista…</button><a href="#/library">${icon('library')} Ver mi librería</a>`
box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => {
const lid = Number(x.dataset.list)
try {
const r = x.checked
? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
: await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' })
listSet = new Set(r.lists)
renderListsBtn()
} catch (err) { toast(err.message); x.checked = !x.checked }
}))
$('#lnew').addEventListener('click', async () => {
const name = prompt('Nombre de la nueva lista:')
if (!name?.trim()) return
try {
const l = await api('/api/lists', { method: 'POST', body: { name } })
const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
listSet = new Set(r.lists)
renderListsBtn()
renderListsMenu()
toast(`Añadido a «${l.name}»`)
} catch (err) { toast(err.message) }
})
}
$('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() })
const renderFollow = () => {
const b = $('#follow')
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
b.classList.toggle('on', isFollowing)
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
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
renderFollow()
toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga')
})
$('#ubdel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return
try {
await api(`/api/books/${id}`, { method: 'DELETE' })
if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid))
toast('Libro eliminado')
location.hash = '#/?source=ub'
} catch (err) { toast(err.message) }
})
$('#drop')?.addEventListener('click', async () => {
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return
await api(`/api/history/${id}`, { method: 'DELETE' })
toast('Quitado del historial')
// Volver a donde se estaba (salvo el lector o la propia ficha, que lo volverían a marcar) o al inicio.
const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login'
location.hash = back ? prevHash : '#/'
})
// Cerrar el desplegable de listas al pulsar fuera.
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
document.addEventListener('click', closeMenu)
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); document.removeEventListener('keydown', onLibKey); offState.listeners.delete(onOff) }
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
const ul = $('.chapters', app)
let desc = store.get('chapterOrderDesc', true)
const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? '<span class="badge">NUEVO</span> ' : '')
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : wantedSet.has(c.id) ? `<span class="queued" title="En cola de descarga">${icon('download')}…</span> ` : '')
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
+ [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ')
// Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si
// no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos).
let extDates = {}
const srcDatesOk = (() => {
const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean)
const n = {}
for (const d of ds) n[d] = (n[d] || 0) + 1
return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25
})()
// Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las
// traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman.
let dateMap = new Map()
function buildDates() {
const DAY = 864e5
const known = chapters.map(c => {
const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null)
const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN
return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t }
}).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n)
let min = Infinity
for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min }
dateMap = new Map()
let prev = null
known.forEach((x, i) => {
if (x.t == null) return
dateMap.set(x.c.id, { t: x.t, approx: false })
if (prev && i - prev.i > 1 && i - prev.i <= 150) {
for (let k = prev.i + 1; k < i; k++) {
const f = (known[k].n - prev.n) / (x.n - prev.n)
dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true })
}
}
prev = { i, n: x.n, t: x.t }
})
}
buildDates()
const chapDate = c => {
const e = dateMap.get(c.id)
if (!e) return ''
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'
pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
ul.classList.toggle('picking', picking)
// 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) {
const ok = pickable.has(c.id)
return `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : KINDS[sel.kind].offTitle(c)}">
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
}
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>`
}
// Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
ul?.addEventListener('click', e => {
const box = e.target.closest('input[data-cid]')
if (!box) return
const i = Number(box.dataset.i)
if (e.shiftKey && lastPick != null) {
const [a, b] = [Math.min(lastPick, i), Math.max(lastPick, i)]
ul.querySelectorAll('input[data-cid]:not(:disabled)').forEach(x => {
const k = Number(x.dataset.i)
if (k >= a && k <= b) { x.checked = box.checked; box.checked ? picked.add(x.dataset.cid) : picked.delete(x.dataset.cid) }
})
} else box.checked ? picked.add(box.dataset.cid) : picked.delete(box.dataset.cid)
lastPick = i
updateSummary()
})
renderList()
if (!isBook) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
renderDl(download)
renderHd(upscale)
renderOff()
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
}
// ---------- 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 ----------
// Seis variantes de roce de papel sintetizadas al momento (sin archivos de audio): ruido filtrado cuyo tono
// sube con la velocidad de la hoja (el aire que mueve), chasquidos sueltos (las fibras del papel) y un golpe
// sordo al caer. Cada vez sale una variante distinta de la anterior, con pequeñas variaciones de duración y
// tono para que no suene nunca igual.
const TURN_SOUNDS = [
{ dur: 0.30, f: [1700, 3600], q: 0.9, peak: 0.55, crackle: 70, thump: 0.25 }, // hoja fina y rápida
{ dur: 0.50, f: [800, 2100], q: 0.7, peak: 0.6, crackle: 45, thump: 0.6 }, // hoja gruesa, lenta
{ dur: 0.36, f: [2300, 5000], q: 1.1, peak: 0.5, crackle: 240, thump: 0.15 }, // papel crujiente
{ dur: 0.42, f: [1200, 2500], q: 0.5, peak: 0.45, crackle: 18, thump: 0 }, // roce suave
{ dur: 0.50, f: [1400, 3100], q: 0.8, peak: 0.6, crackle: 110, thump: 0.4, twoStep: true }, // se despega y cae
{ dur: 0.26, f: [2800, 5800], q: 1.4, peak: 0.4, crackle: 160, thump: 0.12, snap: true }, // satinado, con chasquido
]
function synthTurn(p, sr, rnd = Math.random) {
const n = Math.round(sr * (p.dur + 0.1))
const out = new Float32Array(n)
// Filtro paso banda (biquad RBJ, ganancia 1 en el centro).
const band = () => {
let x1 = 0, x2 = 0, y1 = 0, y2 = 0, b0 = 0, a1 = 0, a2 = 0
return {
set(f, q) { const w = 2 * Math.PI * Math.min(f, sr * 0.45) / sr, al = Math.sin(w) / (2 * q), a0 = 1 + al; b0 = al / a0; a1 = -2 * Math.cos(w) / a0; a2 = (1 - al) / a0 },
run(x) { const y = b0 * (x - x2) - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y },
}
}
const air = band(), fiber = band(), body = band()
fiber.set(4200, 0.8)
body.set(170, 0.9)
const a = Math.log(0.5) / Math.log(p.peak) // sin(π·x^a) tiene su máximo en x = peak
const swell = x => x <= 0 || x >= 1 ? 0 : Math.sin(Math.PI * x ** a) ** 2
const env = x => p.twoStep ? Math.max(0.75 * swell(Math.min(1, x / 0.38)), swell((x - 0.25) / 0.75)) : swell(x)
let flutter = 0, grain = 0, grainK = 1, snapAt = p.snap ? Math.round(sr * p.dur * 0.12) : -1
const thumpAt = Math.round(sr * p.dur * 0.86), thumpK = Math.exp(-1 / (0.035 * sr))
let thump = 0
for (let i = 0; i < n; i++) {
const x = i / (sr * p.dur), e = env(x), w = rnd() * 2 - 1
if (i % 32 === 0) air.set(p.f[0] + (p.f[1] - p.f[0]) * e, p.q)
flutter += (w - flutter) * 0.004 // temblor lento del volumen
let s = air.run(w) * e * (1 + 1.8 * flutter)
// Chasquidos: más frecuentes cuanto más rápido se mueve la hoja.
if (rnd() < (p.crackle * (0.15 + e)) / sr) { grain = Math.max(grain, (0.3 + 0.7 * rnd()) * (0.3 + e)); grainK = Math.exp(-1 / ((0.0006 + 0.0025 * rnd()) * sr)) }
if (i === snapAt) { grain = 1.6; grainK = Math.exp(-1 / (0.004 * sr)) }
grain *= grainK
s += fiber.run(w) * grain * 1.4
if (i === thumpAt) thump = p.thump
thump *= thumpK
s += body.run(w) * thump * 3
out[i] = s
}
let peak = 0
for (const v of out) peak = Math.max(peak, Math.abs(v))
const fade = Math.round(sr * 0.01)
for (let i = 0; i < n; i++) out[i] = (out[i] / (peak || 1)) * Math.min(1, (n - i) / fade)
return out
}
const SOUND_LEVELS = { off: ['Sin sonido', 0], low: ['Sonido bajo', 0.15], mid: ['Sonido medio', 0.3], high: ['Sonido alto', 0.55] }
const sfx = { ctx: null, last: -1 }
// El contexto de audio solo arranca dentro de un gesto del usuario (iOS): se prepara al tocar el lector.
function sfxUnlock() {
try {
const AC = window.AudioContext || window.webkitAudioContext
if (!AC) return null
sfx.ctx ??= new AC()
if (sfx.ctx.state !== 'running') sfx.ctx.resume().catch(() => {})
return sfx.ctx
} catch { return null }
}
// pan: de -1 (izquierda) a 1 (derecha), el lado por el que se mueve la hoja.
function turnSound(level, pan = 0) {
const vol = SOUND_LEVELS[level]?.[1]
if (!vol || document.hidden) return
const ctx = sfxUnlock()
if (!ctx) return
try {
let i
do i = Math.floor(Math.random() * TURN_SOUNDS.length); while (i === sfx.last)
sfx.last = i
const p = TURN_SOUNDS[i], k = 0.88 + Math.random() * 0.24
const data = synthTurn({ ...p, dur: p.dur * (0.88 + Math.random() * 0.24), f: p.f.map(f => f * k) }, ctx.sampleRate)
const buf = ctx.createBuffer(1, data.length, ctx.sampleRate)
buf.getChannelData(0).set(data)
const src = ctx.createBufferSource()
src.buffer = buf
const gain = ctx.createGain()
gain.gain.value = vol * (0.85 + Math.random() * 0.3)
src.connect(gain)
if (pan && ctx.createStereoPanner) {
const sp = ctx.createStereoPanner()
sp.pan.value = pan
gain.connect(sp).connect(ctx.destination)
} else gain.connect(ctx.destination)
src.start()
} catch {}
}
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
function defaultPrefs(manga) {
if (manga.type === 'Libro') return { mode: 'paged', fit: 'height', dir: 'ltr' }
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
const legacy = store.get('readerSettings', {})
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
}
// ---------- Libros: el libro entero se maqueta como un texto continuo (cada capítulo empieza justo debajo del
// anterior) en páginas del tamaño de la pantalla, y cada página se dibuja en un canvas. Al lector le llega como
// una imagen más, así que hoja, sombra, hendidura y sonido no cambian.
const BOOK_DEFAULTS = { size: 100, font: 'serif', margin: 'mid', paper: 'light' }
const BOOK_FONTS = { serif: "Georgia, 'Iowan Old Style', 'Noto Serif', 'Times New Roman', serif", sans: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
const BOOK_PAPERS = { light: { bg: '#fbf9f3', fg: '#22201c', dim: '#8b867b' }, sepia: { bg: '#f3e9d3', fg: '#3a2f21', dim: '#8f7f66' }, dark: { bg: '#1b1a18', fg: '#d8d4ca', dim: '#7c786f' } }
const BOOK_MARGINS = { small: 0.055, mid: 0.085, large: 0.12 }
const BOOK_KEEP_PAGES = 16 // páginas dibujadas que se conservan a cada lado de la actual
const bookSettings = () => ({ ...BOOK_DEFAULTS, ...store.get('bookSettings', {}) })
let bookResume = null // al volver a maquetar (rotar, cambiar la letra): seguir en la misma palabra
let bookCache = null // { id, text } el último libro, para no pedirlo otra vez al reabrir
let bookLayoutCache = null // { key, layout } y su maquetado, para esta pantalla y esta letra
// El libro como una sola lista de bloques. Cada capítulo lleva su división y su título, aunque el texto no lo traiga.
function bookFlatten(chapters) {
const flat = [], chapBlock = []
chapters.forEach((c, i) => {
chapBlock.push(flat.length)
if (i > 0) flat.push({ k: 'div' })
if (c.blocks[0]?.k !== 'h' && !/^Sección \d+$/.test(c.title)) flat.push({ k: 'h', l: 2, r: [[c.title, 0]] })
for (const b of c.blocks) flat.push(b)
})
return { flat, chapBlock }
}
// Una primera pasada solo busca dónde empieza cada página; lo que se dibuja se calcula al pedir esa página. Así un
// libro de miles de páginas no ocupa memoria con la posición de cada palabra.
function makeBookLayout(text, W, H, opts) {
const { flat, chapBlock } = bookFlatten(text.chapters)
const measure = document.createElement('canvas').getContext('2d')
const family = BOOK_FONTS[opts.font] || BOOK_FONTS.serif
const px = Math.min(22, Math.max(15, W / 24)) * (opts.size / 100)
const lineH = Math.round(px * 1.45)
const marginX = Math.round(W * (BOOK_MARGINS[opts.margin] || BOOK_MARGINS.mid))
const top = Math.round(Math.max(px * 2.6, 30)), bottom = Math.round(H - Math.max(px * 2.6, 30))
const cw = W - marginX * 2
const widths = new Map()
const fontOf = (size, style, forceBold) => `${style & 1 ? 'italic ' : ''}${style & 2 || forceBold ? 'bold ' : ''}${size}px ${family}`
const widthOf = (t, font) => {
const key = `${font}|${t}`
let w = widths.get(key)
if (w === undefined) {
if (measure.font !== font) measure.font = font
w = measure.measureText(t).width
widths.set(key, w)
}
return w
}
// Un bloque en líneas: cada línea son cajas (palabras, que pueden cambiar de estilo por dentro).
function breakLines(runs, width, indent, size, forceBold) {
const spaceW = widthOf(' ', fontOf(size, 0, forceBold))
const lines = []
let line = { boxes: [], w: 0, hard: false }
let box = null, space = false
const room = () => width - (lines.length ? 0 : indent)
const endLine = hard => { line.hard = hard; lines.push(line); line = { boxes: [], w: 0, hard: false } }
const place = b => {
if (line.boxes.length && line.w + spaceW + b.w > room()) endLine(false)
line.w += (line.boxes.length ? spaceW : 0) + b.w
line.boxes.push(b)
}
const flushBox = () => {
if (!box) return
// Una palabra más ancha que la línea se corta por letras.
if (box.w > room() && box.frags.length === 1) {
const { font } = box.frags[0]
let cur = ''
for (const ch of Array.from(box.frags[0].t)) {
if (cur && widthOf(cur + ch, font) > width) {
place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
endLine(false)
cur = ''
}
cur += ch
}
if (cur) place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
} else place(box)
box = null
}
for (const [t0, style] of runs) {
const font = fontOf(size, style, forceBold)
for (const m of t0.matchAll(/\n| +|[^ \n]+/g)) {
const t = m[0]
if (t === '\n') { flushBox(); space = false; endLine(true) }
else if (t[0] === ' ') { flushBox(); space = true }
else {
if (space) { flushBox(); space = false }
const w = widthOf(t, font)
box ??= { frags: [], w: 0 }
box.frags.push({ t, font, w })
box.w += w
}
}
}
flushBox()
if (line.boxes.length) endLine(true)
return { lines, spaceW }
}
const headSize = b => Math.round(px * ({ 1: 1.5, 2: 1.35, 3: 1.2 }[b.l] || 1.08))
const headLines = b => { const size = headSize(b), height = Math.round(size * 1.3); return { ...breakLines(b.r, cw, 0, size, true), height } }
const pages = [{ block: 0, line: 0, word: 0, after: true }]
const chapStarts = [0]
// Recorre el libro desde el inicio de una página. Sin items, anota dónde empieza cada página; con items, recoge lo
// que hay que dibujar en esa página y termina.
function* walk(cp, items) {
let y = top, used = false, words = cp.word, after = cp.after
const newPage = (block, line) => {
if (items) return true
pages.push({ block, line, word: words, after })
y = top
used = false
return false
}
const remaining = () => bottom - y
const placeLine = (l, { x, width, indent, center, justify, spaceW, height }) => {
if (items) {
const avail = width - (center ? 0 : indent)
let gap = spaceW
if (justify && !l.hard && l.boxes.length > 1) {
const stretched = spaceW + (avail - l.w) / (l.boxes.length - 1)
if (stretched < spaceW + px * 1.2) gap = stretched // una línea muy suelta se queda sin justificar
}
let cx = center ? x + (width - l.w) / 2 : x + indent
const segs = []
for (const b of l.boxes) {
for (const f of b.frags) { segs.push({ t: f.t, font: f.font, x: cx }); cx += f.w }
cx += gap
}
items.push({ line: segs, y: y + height * 0.78 })
}
y += height
words += l.boxes.length
used = true
}
for (let b = cp.block; b < flat.length; b++) {
if (!items && !(b & 127)) yield
const block = flat[b]
const from = b === cp.block ? cp.line : 0
if (block.k === 'div') {
// División de capítulo: el capítulo sigue en la misma página si caben la línea, su título y algo de texto.
const nx = flat[b + 1]
let need = lineH * 5
if (nx?.k === 'h') { const h = headLines(nx); need += h.lines.length * h.height }
if (used && remaining() < need && newPage(b, 0)) return
if (!items) chapStarts.push(pages.length - 1)
if (used) {
y += lineH * 1.2
items?.push({ rule: true, y })
y += lineH * 0.6
}
after = true
continue
}
if (block.k === 'hr') {
if (remaining() < lineH * 3 && used && newPage(b, 0)) return
y += lineH * 0.7
items?.push({ rule: true, y })
y += lineH * 0.8
used = true
after = true
continue
}
if (block.k === 'img') {
if (!block.w || !block.h) continue
const fit = Math.min(cw / block.w, (bottom - top) / block.h, 2)
const w = block.w * fit, h = block.h * fit
if (h > remaining() && used && newPage(b, 0)) return
items?.push({ n: block.n, x: (W - w) / 2, y, w, h })
y += h + lineH * 0.5
used = true
after = true
continue
}
if (block.k === 'h') {
const { lines, spaceW, height } = headLines(block)
if (!from) {
const need = lines.length * height + lineH * 3 + (used ? lineH * 1.2 : 0)
if (used && remaining() < need && newPage(b, 0)) return // el título no se queda solo al pie
if (used) y += lineH * 1.2
}
for (let i = from; i < lines.length; i++) {
if (remaining() < height && used && newPage(b, i)) return
placeLine(lines[i], { x: marginX, width: cw, indent: 0, center: true, justify: false, spaceW, height })
}
y += lineH * 0.8
after = true
continue
}
const quote = block.k === 'q'
const verse = !!block.v
const center = !!block.c
const inset = quote ? Math.round(px * 1.4) : 0
const width = cw - inset * 2
const indent = !after && !quote && !verse && !center ? Math.round(px * 1.3) : 0
const { lines, spaceW } = breakLines(block.r, width, indent, px, false)
if (quote && used && !from) y += lineH * 0.3
for (let i = from; i < lines.length;) {
const fits = Math.floor(remaining() / lineH)
let k = Math.min(fits, lines.length - i)
if (!verse) {
// Ni una primera línea sola al pie ni una última línea sola arriba de la página siguiente.
if (i === 0 && k === 1 && lines.length > 1) k = 0
else if (i + k === lines.length - 1) k = k >= 2 ? k - 1 : 0
}
if (k <= 0) {
if (!used) k = Math.max(1, Math.min(fits, lines.length - i)) // no cabe ni en una página vacía: no dar vueltas
else { if (newPage(b, i)) return; continue }
}
for (let n = 0; n < k; n++) placeLine(lines[i + n], { x: marginX + inset, width, indent: i + n === 0 ? indent : 0, center, justify: !verse && !center, spaceW, height: lineH })
i += k
if (i < lines.length && newPage(b, i)) return
}
if (quote) y += lineH * 0.3
after = quote
}
}
return {
pages, chapStarts, chapBlock, flat, top, cw, family, footPx: Math.max(11, Math.round(px * 0.68)),
// Reparte el libro cediendo el paso de vez en cuando, para que la pantalla no se quede congelada.
async build() {
let t = performance.now()
for (const _ of walk(pages[0], null)) {
if (performance.now() - t > 40) { await new Promise(r => setTimeout(r)); t = performance.now() }
}
},
capture(i) {
const items = []
const run = walk(pages[i], items)
while (!run.next().done);
return items
},
}
}
async function loadBookText(mangaId) {
if (bookCache?.id === mangaId) return bookCache.text
const text = await api(`/api/book/${mangaId}/text`)
bookCache = { id: mangaId, text }
return text
}
async function createBook(text, mangaId, size) {
const opts = bookSettings()
const colors = BOOK_PAPERS[opts.paper] || BOOK_PAPERS.light
const { w: W, h: H } = size
const key = JSON.stringify([mangaId, W, H, opts])
let layout = bookLayoutCache?.key === key ? bookLayoutCache.layout : null
if (!layout) {
layout = makeBookLayout(text, W, H, opts)
await layout.build()
bookLayoutCache = { key, layout }
}
const { pages, chapStarts, chapBlock, flat } = layout
const dpr = Math.min(window.devicePixelRatio || 1, 3, 4096 / Math.max(W, H))
const canvas = document.createElement('canvas')
canvas.width = Math.round(W * dpr)
canvas.height = Math.round(H * dpr)
const ctx = canvas.getContext('2d', { alpha: false })
const urls = new Map()
const waiting = new Map()
const queue = []
const images = new Map() // n -> { im, ready }
let released = false, timer = 0
const imageOf = n => {
let e = images.get(n)
if (!e) {
const im = new Image()
e = { im, ok: false, ready: new Promise(r => { im.onload = () => { e.ok = im.naturalWidth > 0; e.failed = !e.ok; r() }; im.onerror = () => { e.failed = true; r() } }) }
images.set(n, e)
im.src = `/api/book/${mangaId}/img/${n}`
}
return e
}
const chapterOfBlock = block => {
let lo = 0, hi = chapBlock.length - 1
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapBlock[mid] <= block) lo = mid; else hi = mid - 1 }
return lo
}
function draw(i, items) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.fillStyle = colors.bg
ctx.fillRect(0, 0, W, H)
ctx.textBaseline = 'alphabetic'
ctx.textAlign = 'left'
ctx.fillStyle = colors.fg
let font = ''
for (const it of items) {
if (it.line) {
for (const s of it.line) {
if (s.font !== font) { ctx.font = font = s.font }
ctx.fillText(s.t, s.x, it.y)
}
} else if (it.rule) {
ctx.fillStyle = colors.dim
ctx.fillRect(W / 2 - W * 0.08, it.y, W * 0.16, 1)
ctx.fillStyle = colors.fg
} else {
const e = imageOf(it.n)
if (e.ok) ctx.drawImage(e.im, it.x, it.y, it.w, it.h)
}
}
// Título del capítulo arriba (salvo donde empieza) y número abajo: guías de lectura, no parte del texto.
ctx.fillStyle = colors.dim
ctx.textAlign = 'center'
ctx.font = `${layout.footPx}px ${layout.family}`
const p = pages[i]
const c = chapterOfBlock(p.block)
const title = text.chapters[c].title
const atStart = p.line === 0 && (p.block === chapBlock[c] || (p.block === chapBlock[c] + 1 && flat[p.block].k === 'h'))
if (!atStart && title && !/^Sección \d+$/.test(title)) {
let t = title
if (ctx.measureText(t).width > layout.cw) {
while (t && ctx.measureText(`${t}…`).width > layout.cw) t = t.slice(0, -1)
t += '…'
}
ctx.fillText(t, W / 2, layout.top - layout.footPx * 1.2)
}
ctx.fillText(`${i + 1} / ${pages.length}`, W / 2, H - layout.footPx * 1.4)
}
const pendingImages = items => items.filter(it => it.n != null && !images.get(it.n)?.ok && !images.get(it.n)?.failed).map(it => imageOf(it.n).ready)
const settle = (i, url) => {
if (!urls.has(i)) urls.set(i, url)
for (const cb of waiting.get(i) || []) cb(urls.get(i))
waiting.delete(i)
}
const forget = i => {
const u = urls.get(i)
if (u?.startsWith('blob:')) URL.revokeObjectURL(u)
urls.delete(i)
}
async function tick() {
timer = 0
if (released || !queue.length) return
if (book.busy?.()) { timer = setTimeout(tick, 150); return } // no dibujar mientras gira la hoja
const i = queue.shift()
if (urls.has(i)) { settle(i, urls.get(i)); timer = setTimeout(tick); return }
const items = layout.capture(i)
timer = -1 // ocupado: que nadie arranque otra vuelta mientras llegan las imágenes
await Promise.all(pendingImages(items))
if (released) return
draw(i, items)
canvas.toBlob(blob => {
if (released) return
if (blob && !urls.has(i)) settle(i, URL.createObjectURL(blob))
else settle(i, urls.get(i) ?? book.src(i))
timer = setTimeout(tick, 16)
}, 'image/png')
}
const book = {
count: pages.length, W, H, busy: null, onChange: null,
// Síncrono: la página hace falta ya (la URL no cambia una vez entregada; identifica su textura). Si aún no han
// llegado sus imágenes, se dibuja sin ellas y se avisa para cambiarla al llegar.
src(i) {
if (urls.has(i)) return urls.get(i)
const items = layout.capture(i)
const pending = pendingImages(items)
draw(i, items)
urls.set(i, canvas.toDataURL('image/png'))
if (waiting.has(i)) settle(i, urls.get(i))
if (pending.length) {
Promise.all(pending).then(() => {
if (released || !urls.has(i)) return
urls.delete(i)
book.onChange?.(i)
})
}
return urls.get(i)
},
// Asíncrono: se dibuja cuando haya un momento libre, en el orden en que se pidió.
whenReady(i, cb) {
if (urls.has(i)) return cb(urls.get(i))
if (!waiting.has(i)) { waiting.set(i, []); queue.push(i) }
waiting.get(i).push(cb)
if (!timer) timer = setTimeout(tick)
},
// Lo dibujado lejos de la página actual se suelta; si se vuelve, se dibuja otra vez.
trim(center) {
for (const i of [...urls.keys()]) if (Math.abs(i - center) > BOOK_KEEP_PAGES) forget(i)
},
near: (i, center) => Math.abs(i - center) <= BOOK_KEEP_PAGES,
wordAt: i => pages[Math.min(i, pages.length - 1)]?.word ?? 0,
pageAtWord(word) {
let lo = 0, hi = pages.length - 1
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pages[mid].word <= word) lo = mid; else hi = mid - 1 }
return lo
},
chapterStart: c => chapStarts[c] ?? 0,
// Si varios capítulos empiezan en una página, cuenta el último: ahí es donde sigue la lectura.
chapterAt(p) {
let lo = 0, hi = chapStarts.length - 1
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapStarts[mid] <= p) lo = mid; else hi = mid - 1 }
return lo
},
chapterPages: c => Math.max(1, (chapStarts[c + 1] ?? pages.length) - chapStarts[c]),
release() {
released = true
clearTimeout(timer)
for (const i of [...urls.keys()]) forget(i)
canvas.width = canvas.height = 0
},
}
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>'
const lang = store.get(`lang:${mangaId}`, me.lang)
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) }
const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`).catch(err => {
// Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo.
if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err
const pos = store.get(`bookPos:${mangaId}`)
return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 }
}),
])
const { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
const idx = chapters.findIndex(c => c.id === chapterId)
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).
const isBook = !!chap.text
const noun = isBook ? 'libro' : 'manga'
const bookSize = () => {
const vw = window.innerWidth, vh = window.innerHeight
return { w: Math.floor(Math.min(isDouble() ? vw / 2 : vw, vh * (vh < 600 ? 1.05 : 0.8))), h: vh }
}
let book = null
if (isBook) {
settings.fit = 'height'
const opening = location.hash
app.innerHTML = '<div class="reader"><div class="loading">Preparando el libro…</div></div>'
book = await createBook(await loadBookText(mangaId), mangaId, bookSize())
if (location.hash !== opening) { book.release(); return }
book.busy = () => turning
}
// 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
if (book) {
// El progreso de un libro se guarda por capítulo. Con otra pantalla o tamaño de letra un capítulo tiene otro
// número de páginas: ir a la equivalente.
const c = Math.max(idx, 0)
const pagesHere = book.chapterPages(c)
if (chap.savedTotal > 1 && chap.savedTotal !== pagesHere) wanted = Math.round((wanted / (chap.savedTotal - 1)) * (pagesHere - 1))
wanted = book.chapterStart(c) + Math.min(wanted, pagesHere - 1)
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)
// Guardado del progreso (con debounce y al salir de la página).
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: 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 (!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,
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
})
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
const onVis = () => document.visibilityState === 'hidden' && beacon()
document.addEventListener('visibilitychange', onVis)
window.addEventListener('pagehide', beacon)
saveNow()
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">
<div class="rbar top">
<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>
<button class="btn small" id="modequick"></button>
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
</div>
<div class="rpanel" hidden>
<select id="mode" title="Modo de lectura (se recuerda para este ${noun})">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="fit" ${isBook ? 'hidden' : ''} title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
<select id="dir" title="Dirección (se recuerda para este ${noun})"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<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>'
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
${isBook ? `<select id="bsize" title="Tamaño de letra">${[80, 90, 100, 110, 120, 135, 150, 175].map(n => `<option value="${n}">Letra ${n}%</option>`).join('')}</select>
<select id="bfont" title="Tipo de letra"><option value="serif">Con remates</option><option value="sans">Sin remates</option></select>
<select id="bmargin" title="Márgenes"><option value="small">Margen pequeño</option><option value="mid">Margen medio</option><option value="large">Margen grande</option></select>
<select id="bpaper" title="Papel"><option value="light">Papel claro</option><option value="sepia">Papel sepia</option><option value="dark">Papel oscuro</option></select>` : ''}
${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">
${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>
${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
$('#fit').value = settings.fit
$('#dir').value = settings.dir
// Botón rápido páginas ⇄ vertical (vuelve al último modo por páginas usado).
let lastPaged = settings.mode === 'double' ? 'double' : 'paged'
function renderModeQuick() {
const b = $('#modequick')
b.innerHTML = icon(isPaged() ? 'scroll' : 'pages')
b.title = isPaged() ? 'Leer en vertical (desplazamiento continuo)' : 'Leer por páginas'
$('#mode').value = settings.mode
}
$('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none'
$('#bright').value = settings.brightness
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">${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}`)) {
store.set(`autoPrefs:${mangaId}`, 1)
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
}
// Libro: al cruzar a otro capítulo leyendo, los que quedan atrás cuentan como leídos; a uno lejano (la barra, el
// selector) se salta sin marcar nada.
let lastBookPage = page
const syncBookNav = () => {
$('#bprev').style.display = curChap > 0 || (shown[0] ?? page) > 0 ? '' : 'none'
$('#bnext').style.display = curChap < chapters.length - 1 ? '' : 'none'
$('#chap').value = chapters[curChap].id
}
const syncBookChapter = () => {
const p = lastShown()
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 = seq.chapterAt(p)
if (c !== curChap) {
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
for (let j = curChap; j < c; 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 (seq) syncBookChapter()
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1
}
let observer = null
let atEnd = false
// Libro: ¿la pantalla ya pide otro tamaño de página? La barra del navegador móvil al aparecer no cuenta.
const sizeChanged = () => {
const s = bookSize()
return s.w !== book.W || Math.abs(s.h - book.H) > 120
}
function reopenBook() {
bookResume = { mangaId, word: book.wordAt(shown[0] ?? page) }
history.replaceState(null, '', readUrl(mangaId, chapters[curChap].id))
router()
}
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
const wide = new Set()
const isWide = img => img.naturalWidth > img.naturalHeight * 1.1
const groupOf = i => (isDouble() ? spreadOf(i) : [i])
// Las páginas que quedan frente a frente en el libro abierto: la portada sola, luego de dos en dos; una página
// ancha ocupa las dos caras.
function spreadOf(i) {
for (let s = 0; s < total;) {
// 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
}
return [i]
}
// Las 6 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso), ya
// decodificadas. Se guardan las referencias para que la descarga no se pierda si se recoge la basura.
const preloaded = new Map()
function preload() {
const want = []
for (let k = 1; k <= 6 && shown.at(-1) + k < total; k++) want.push(shown.at(-1) + k)
for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) want.push(shown[0] - k)
for (const i of want) {
if (preloaded.has(i)) continue
const im = new Image()
im.onload = () => {
if (!isWide(im) || wide.has(i)) return
wide.add(i)
refreshGutters() // una página ancha cambia las parejas de las páginas de delante
}
preloaded.set(i, im)
const load = src => { im.src = src; im.decode?.().catch(() => {}) }
if (book) book.whenReady(i, load) // un libro dibuja sus páginas poco a poco, sin frenar la lectura
else load(pages[i])
}
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
scheduleLeafTextures()
}
const imgReady = im => im.complete && im.naturalWidth > 0
// Lado de la hendidura del lomo: l o r (borde de la página); una página ancha no lleva (none).
// En la vista de una página, la cara de delante de cada pareja lo tiene al lado contrario que la hoja que gira.
const gutterOf = (i, n) => {
if (wide.has(i)) return 'none'
const rtl = settings.dir === 'rtl'
if (shown.length === 2) return rtl === (n === 0) ? 'l' : 'r'
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) }); 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')
if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false
const from = Number(imgs[0].dataset.i)
return from !== shown[0] && spreadOf(from).includes(shown[0])
}
// Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que
// llegue o pasen 4 s, en vez de pasar la hoja y dejar negro debajo. Si se pide otra página mientras,
// manda la última.
let showSeq = 0, waitTimer = 0
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
function showPaged(dir = 0) {
const seq = ++showSeq
clearTimeout(waitTimer)
box.classList.remove('waiting')
finishTurn()
const stage = document.createElement('div')
stage.className = 'stage'
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) && !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(() => {})))
Promise.race([arrived, new Promise(r => setTimeout(r, 4000))]).then(() => {
if (seq !== showSeq) return
clearTimeout(waitTimer)
box.classList.remove('waiting')
if (!atEnd && isPaged() && box.isConnected) {
grabY = touchedAt
try { presentPaged(stage, dir) } finally { grabY = null }
}
})
return
}
presentPaged(stage, dir)
}
function presentPaged(stage, dir) {
const old = $('.stage', box)
// Lo que aún no ha llegado se ve con un indicador de carga y aparece con un fundido, no de golpe.
const imgs = [...stage.querySelectorAll('img')]
const pending = imgs.filter(im => !imgReady(im))
pending.forEach(im => {
im.classList.add('pending')
const done = () => {
im.classList.remove('pending')
if (!stage.querySelector('img.pending')) stage.classList.remove('loading')
}
im.addEventListener('load', done, { once: true })
im.addEventListener('error', done, { once: true })
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
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)
if (isWide(img)) img.dataset.gutter = 'none'
if (isWide(img) && !wide.has(i)) {
wide.add(i)
if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar
}
}, { once: true }))
resetZoom()
window.scrollTo(0, 0)
preload()
}
// Giro entre dos parejas, en la vista de una página: la hoja lleva la página vieja por una cara y la nueva por la otra.
function isBookTurn(old, dir) {
const imgs = old.querySelectorAll('img')
if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false
const from = Number(imgs[0].dataset.i), to = shown[0]
if (Math.abs(from - to) !== 1) return false
const [opening, turned] = dir > 0 ? [to, from] : [from, to]
return spreadOf(opening)[0] === opening && spreadOf(opening).length === 2 && !wide.has(turned)
}
// --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
// al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
let turning = null // { anims, cleanup } (y move/release si sigue al dedo)
let wantDrag = false
let grabY = null // altura de la pantalla por la que el dedo tocó o agarró la página
// Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página).
const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12)
function finishTurn() {
if (!turning) return
const t = turning
turning = null
t.anims.forEach(a => { try { a.cancel() } catch {} })
t.cleanup()
}
// Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API
// que usa finishTurn: cancel() y la promesa finished.
function frameAnim(duration, step) {
let raf = 0, settle
const finished = new Promise((res, rej) => { settle = { res, rej } })
const t0 = performance.now()
const tick = now => {
const g = Math.min(1, (now - t0) / duration)
step(g)
if (g < 1) raf = requestAnimationFrame(tick)
else settle.res()
}
step(0)
raf = requestAnimationFrame(tick)
return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } }
}
// Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior,
// así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso
// (la página siguiente en doble página, o papel en blanco) por detrás.
// Ángulos de la hoja dividida en N tiras, común a la versión WebGL y a la de capas.
function curlCurve(N) {
// Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por
// delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el
// mismo gesto al revés, con el borde libre llegando primero).
function angles(g, reverse) {
const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2
const bend = Math.sin(Math.PI * g) * 125
return Array.from({ length: N }, (_, i) => {
const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45)))
return reverse ? 180 - a : a
})
}
// Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una
// sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo.
function span(reverse) {
const seen = g => angles(g, reverse).some(a => a < 88)
let g0 = 0, g1 = 1
while (g0 < 1 && !seen(g0)) g0 += 0.01
while (g1 > g0 && !seen(g1)) g1 -= 0.01
return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
}
// Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo)
// y el avance g que la pone donde está el dedo.
const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N
function solve(target, reverse) {
let lo = 0, hi = 1
for (let k = 0; k < 18; k++) {
const m = (lo + hi) / 2
if ((edge(m, reverse) > target) !== reverse) lo = m
else hi = m
}
return (lo + hi) / 2
}
return { angles, span, solve }
}
// --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl
// (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU.
const LEAF_COLS = 32, LEAF_ROWS = 8
const BOOK_FOLLOW_FROM = 0.25 // parte del giro antes de que la vista empiece a acompañar a la hoja
const LEAF_SHADOW_STEP = 2 // columnas de la hoja por cada tramo de sombra
const SHADOW_DARK = 0.95, SHADOW_SOFT = 0.4, SHADOW_FALL = 3 // oscuridad junto al papel, ancho del borde y caída con la altura
let leafGL = null, leafGLOff = false, leafTimer = 0
const leafTextures = new Map() // URL de la página → textura
const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…)
function initLeafGL() {
if (leafGL || leafGLOff) return leafGL
const canvas = document.createElement('canvas')
canvas.className = 'curlgl'
const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true, depth: true, powerPreference: 'high-performance' })
const shader = (type, src) => { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); return sh }
const prog = gl?.createProgram()
if (gl) {
// Misma proyección que perspective en CSS: el origen en el centro y la escala 1 / (1 - z / perspectiva).
gl.attachShader(prog, shader(gl.VERTEX_SHADER, `#version 300 es
uniform vec3 uView;
layout(location = 0) in vec4 aPos;
layout(location = 1) in vec2 aUV;
out vec2 vUV;
out float vC;
void main() {
float w = 1.0 - aPos.z / uView.z;
gl_Position = vec4(aPos.x / uView.x * 2.0 - 1.0, 1.0 - aPos.y / uView.y * 2.0, 1.05128 * w - 0.10256, w);
vUV = aUV;
vC = aPos.w;
}`))
// La cara se oscurece al inclinarse. El sesgo negativo evita que la página se vea blanda con los mipmaps.
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, `#version 300 es
precision highp float;
uniform sampler2D uFront, uBack;
uniform bool uPaper, uSpineLeft, uWide;
in vec2 vUV;
in float vC;
out vec4 color;
// La hendidura del lomo: los mismos puntos que data-gutter en style.css, para que no salte al empezar el giro.
float gutter(float d) {
if (d < 0.012) return mix(0.4, 0.6, d / 0.012);
if (d < 0.03) return mix(0.6, 0.8, (d - 0.012) / 0.018);
if (d < 0.06) return mix(0.8, 0.93, (d - 0.03) / 0.03);
if (d < 0.1) return mix(0.93, 1.0, (d - 0.06) / 0.04);
return 1.0;
}
void main() {
vec4 t;
float b;
float fromSpine = uSpineLeft ? vUV.x : 1.0 - vUV.x;
if (gl_FrontFacing) {
t = texture(uFront, vUV, -0.5);
b = (1.0 - (1.0 - vC) * 0.3) * (uWide ? 1.0 : gutter(fromSpine));
} else {
t = uPaper ? vec4(0.914, 0.898, 0.863, 1.0) : texture(uBack, vec2(1.0 - vUV.x, vUV.y), -0.5);
b = (1.0 - (1.0 + vC) * 0.3) * gutter(fromSpine);
}
color = vec4(t.rgb * b, t.a);
}`))
gl.linkProgram(prog)
}
if (!gl || !gl.getProgramParameter(prog, gl.LINK_STATUS)) { leafGLOff = true; return null }
gl.useProgram(prog)
gl.uniform1i(gl.getUniformLocation(prog, 'uFront'), 0)
gl.uniform1i(gl.getUniformLocation(prog, 'uBack'), 1)
gl.enable(gl.DEPTH_TEST)
gl.depthFunc(gl.LEQUAL)
gl.clearColor(0, 0, 0, 0)
const count = (LEAF_COLS + 1) * (LEAF_ROWS + 1)
const posBuf = gl.createBuffer(), uvBuf = gl.createBuffer(), indexBuf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf)
gl.bufferData(gl.ARRAY_BUFFER, count * 16, gl.DYNAMIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf)
gl.bufferData(gl.ARRAY_BUFFER, count * 8, gl.DYNAMIC_DRAW)
gl.enableVertexAttribArray(1)
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0)
const at = (c, f) => c * (LEAF_ROWS + 1) + f
const index = new Uint16Array(LEAF_COLS * LEAF_ROWS * 6)
let n = 0
for (let c = 0; c < LEAF_COLS; c++) for (let f = 0; f < LEAF_ROWS; f++) {
index.set([at(c, f), at(c, f + 1), at(c + 1, f), at(c + 1, f), at(c, f + 1), at(c + 1, f + 1)], n)
n += 6
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW)
// Sombra sobre el papel: la proyección de la hoja con la luz de frente. Se pinta detrás de la hoja, sin imagen.
const shadowProg = gl.createProgram()
gl.attachShader(shadowProg, shader(gl.VERTEX_SHADER, `#version 300 es
uniform vec2 uSize;
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aRamp;
layout(location = 2) in float aAlpha;
out vec2 vRamp;
out float vAlpha;
void main() {
gl_Position = vec4(aPos.x / uSize.x * 2.0 - 1.0, 1.0 - aPos.y / uSize.y * 2.0, 0.95, 1.0);
vRamp = aRamp;
vAlpha = aAlpha;
}`))
gl.attachShader(shadowProg, shader(gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float;
in vec2 vRamp;
in float vAlpha;
out vec4 color;
void main() {
vec2 r = vRamp * vRamp * (3.0 - 2.0 * vRamp);
color = vec4(0.0, 0.0, 0.0, vAlpha * r.x * r.y);
}`))
gl.linkProgram(shadowProg)
if (!gl.getProgramParameter(shadowProg, gl.LINK_STATUS)) { leafGLOff = true; return null }
// Cada tramo son 3×3 cuadros: el centro es la tira proyectada y el resto, su borde difuso.
const shadowSegs = LEAF_COLS / LEAF_SHADOW_STEP
const shadowVao = gl.createVertexArray(), shadowBuf = gl.createBuffer(), shadowIndex = gl.createBuffer()
gl.bindVertexArray(shadowVao)
gl.bindBuffer(gl.ARRAY_BUFFER, shadowBuf)
gl.bufferData(gl.ARRAY_BUFFER, shadowSegs * 16 * 20, gl.DYNAMIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0)
gl.enableVertexAttribArray(1)
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 8)
gl.enableVertexAttribArray(2)
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 20, 16)
const shadowIdx = new Uint16Array(shadowSegs * 54)
let m = 0
for (let q = 0; q < shadowSegs; q++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) {
const v = (a, b) => q * 16 + a * 4 + b
shadowIdx.set([v(j, k), v(j, k + 1), v(j + 1, k), v(j + 1, k), v(j, k + 1), v(j + 1, k + 1)], m)
m += 6
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, shadowIndex)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, shadowIdx, gl.STATIC_DRAW)
gl.bindVertexArray(null)
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
// Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro.
canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } })
leafGL = { canvas, gl, prog, posBuf, uvBuf, aniso, shadowProg, shadowVao, shadowBuf, shadowCount: m, shadowData: new Float32Array(shadowSegs * 16 * 5),
uSize: gl.getUniformLocation(shadowProg, 'uSize'), positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n,
uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), uSpineLeft: gl.getUniformLocation(prog, 'uSpineLeft'), uWide: gl.getUniformLocation(prog, 'uWide'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) }
return leafGL
}
function leafTexture(im) {
const known = leafTextures.get(im.src)
if (known) return known
const { gl, aniso, maxSize } = leafGL
if (Math.max(im.naturalWidth, im.naturalHeight) > maxSize) throw new Error('página demasiado grande')
const tex = gl.createTexture()
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, tex)
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im)
gl.generateMipmap(gl.TEXTURE_2D)
if (gl.getError() !== gl.NO_ERROR) throw new Error('sin memoria para la textura')
} catch (e) { gl.deleteTexture(tex); throw e }
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
leafTextures.set(im.src, tex)
return tex
}
// Sube con antelación, en reposo y de una en una, las páginas de los giros posibles: subirla al empezar el
// 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') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay)
}
function nextLeafTexture() {
if (turning) return scheduleLeafTextures(400)
const n = shown.length
const near = []
for (let k = 1; k <= n; k++) near.push(shown.at(-1) + k, shown[0] - k)
const wanted = [...box.querySelectorAll('.stage img'), ...near.map(i => preloaded.get(i)).filter(Boolean)]
const keep = new Set(wanted.map(im => im.src))
for (const [src, tex] of leafTextures) if (!keep.has(src)) { leafGL?.gl.deleteTexture(tex); leafTextures.delete(src) }
const missing = wanted.filter(im => !leafTextures.has(im.src) && !leafNoTexture.has(im.src))
const im = missing.find(imgReady)
if (!im) { if (missing.length) scheduleLeafTextures(800); return }
if (!initLeafGL()) return
try { leafTexture(im) } catch { leafNoTexture.add(im.src) }
scheduleLeafTextures(120)
}
function freeLeafGL() {
leafGLOff = true
clearTimeout(leafTimer)
leafGL?.gl.getExtension('WEBGL_lose_context')?.loseContext()
leafGL = null
leafTextures.clear()
}
// Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl.
function glCurl(r, frontIm, backIm, spineLeft, paper = r) {
if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null
const L = leafGL
const { gl, canvas, positions, uvs } = L
let front, back
try {
front = leafTexture(frontIm)
back = backIm ? leafTexture(backIm) : null
} catch { leafNoTexture.add(frontIm.src); if (backIm) leafNoTexture.add(backIm.src); return null }
const W = box.clientWidth, H = box.clientHeight, N = LEAF_COLS, R = LEAF_ROWS
const pw = Math.round(W * devicePixelRatio), ph = Math.round(H * devicePixelRatio)
if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph }
Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` })
gl.viewport(0, 0, pw, ph)
gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200)
gl.useProgram(L.shadowProg)
gl.uniform2f(L.uSize, W, H)
gl.useProgram(L.prog)
const scale = pw / W
gl.scissor(Math.round(paper.l * scale), Math.round((H - paper.t - paper.h) * scale), Math.round(paper.w * scale), Math.round(paper.h * scale))
gl.uniform1i(L.uPaper, back ? 0 : 1)
gl.uniform1i(L.uSpineLeft, spineLeft ? 1 : 0)
gl.uniform1i(L.uWide, frontIm.dataset.gutter === 'none' ? 1 : 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, front)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, back)
// Las columnas van del lomo al borde libre: con el lomo a la derecha, la malla queda al revés.
gl.frontFace(spineLeft ? gl.CCW : gl.CW)
for (let c = 0, n = 0; c <= N; c++) for (let f = 0; f <= R; f++) {
uvs[n++] = spineLeft ? c / N : 1 - c / N
uvs[n++] = f / R
}
gl.bindBuffer(gl.ARRAY_BUFFER, L.uvBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, uvs)
gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf)
const curve = curlCurve(N)
const step = (spineLeft ? r.w : -r.w) / N
const sgn = spineLeft ? -1 : 1
const spineX = r.l + (spineLeft ? 0 : r.w)
let pivotY = r.h / 2
const rad = Math.PI / 180
const mul = (a, b) => [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6], a[0] * b[1] + a[1] * b[4] + a[2] * b[7], a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
]
// Con la luz de frente, cada tira cae en vertical sobre el papel (x e y sin cambiar). La altura decide el borde:
// cerca del papel, nítido y oscuro; lejos, ancho y claro. Con MAX, los tramos que se solapan no oscurecen de más,
// y con la prueba de profundidad no se pinta lo que tapa la hoja.
function drawShadow(lift) {
const data = L.shadowData, col = (R + 1) * 4, strength = SHADOW_DARK * Math.min(1, lift * 4), fall = r.w * SHADOW_FALL
let n = 0
for (let c = 0; c < N; c += LEAF_SHADOW_STEP) {
const i = c * col, e = (c + LEAF_SHADOW_STEP) * col, down = R * 4
const ax = positions[i], ay = positions[i + 1], bx = positions[i + down], by = positions[i + down + 1], cx = positions[e], cy = positions[e + 1]
const len = Math.hypot(bx - ax, by - ay) || 1, vx = (bx - ax) / len, vy = (by - ay) / len
let ux = vy, uy = -vx
if ((cx - ax) * ux + (cy - ay) * uy < 0) { ux = -ux; uy = -uy }
for (let j = 0; j < 4; j++) for (let k = 0; k < 4; k++) {
const at = (j < 2 ? i : e) + (k < 2 ? 0 : down)
const height = Math.abs(positions[at + 2]), soft = 2 + height * SHADOW_SOFT
const oj = j === 0 ? -1 : j === 3 ? 1 : 0, ok = k === 0 ? -1 : k === 3 ? 1 : 0
data[n++] = positions[at] + (ux * oj + vx * ok) * soft
data[n++] = positions[at + 1] + (uy * oj + vy * ok) * soft
data[n++] = oj ? 0 : 1
data[n++] = ok ? 0 : 1
data[n++] = strength / (1 + height / fall)
}
}
gl.useProgram(L.shadowProg)
gl.bindVertexArray(L.shadowVao)
gl.bindBuffer(gl.ARRAY_BUFFER, L.shadowBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data)
gl.enable(gl.SCISSOR_TEST)
gl.enable(gl.BLEND)
gl.blendEquation(gl.MAX)
gl.blendFunc(gl.ONE, gl.ONE)
gl.depthMask(false)
gl.drawElements(gl.TRIANGLES, L.shadowCount, gl.UNSIGNED_SHORT, 0)
gl.depthMask(true)
gl.disable(gl.BLEND)
gl.disable(gl.SCISSOR_TEST)
gl.bindVertexArray(null)
gl.useProgram(L.prog)
gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf)
}
// Devuelve cuánto está levantada la hoja (0 a 1). Encadena los giros de tira en
// tira como lo hacen las capas anidadas de paperCurl.
function set(g, reverse, tiltZ = 0, tiltX = 0, cam = 0) {
const a = curve.angles(g, reverse)
let m = [1, 0, 0, 0, 1, 0, 0, 0, 1], x = 0, y = 0, z = 0, prev = 0, prevC = 1, lift = 0, n = 0
const column = c => {
for (let f = 0; f <= R; f++) {
const v = (r.h * f) / R - pivotY
positions[n++] = spineX + cam + x + m[1] * v
positions[n++] = r.t + pivotY + y + m[4] * v
positions[n++] = z + m[7] * v
positions[n++] = c
}
}
for (let i = 0; i < N; i++) {
const c = Math.cos(a[i] * rad)
if (i) { x += m[0] * step; y += m[3] * step; z += m[6] * step }
const t = sgn * (a[i] - prev) * rad
m = mul(m, [Math.cos(t), 0, Math.sin(t), 0, 1, 0, -Math.sin(t), 0, Math.cos(t)])
if (i && (tiltZ || tiltX)) {
const tz = ((tiltZ * c) / (N - 1)) * rad, tx = ((tiltX * c) / (N - 1)) * rad
m = mul(mul(m, [Math.cos(tz), -Math.sin(tz), 0, Math.sin(tz), Math.cos(tz), 0, 0, 0, 1]), [1, 0, 0, 0, Math.cos(tx), -Math.sin(tx), 0, Math.sin(tx), Math.cos(tx)])
}
column(i ? (c + prevC) / 2 : c)
prev = a[i]
prevC = c
lift = Math.max(lift, Math.sin(a[i] * rad))
}
x += m[0] * step; y += m[3] * step; z += m[6] * step
column(prevC)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0)
if (lift > 0) drawShadow(lift)
return lift
}
const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 }
return { el: canvas, gl: true, set, span: curve.span, solve: curve.solve, pivot }
}
function paperCurl(r, frontSrc, backSrc, spineLeft, bent = false) {
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
const w = r.w / N
// Cada cara se sale OV px por los lados de su tira para tapar la unión con la vecina: sin solape,
// Safari deja una rendija oscura entre tiras.
// Curvada, las tiras dejan de encajar justas lejos del punto fijo: hace falta más solape.
const OV = bent ? 4 : 1.5
const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
const el = document.createElement('div')
el.className = 'curl'
const strips = []
let parent = el
for (let i = 0; i < N; i++) {
const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira
const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta
const s = document.createElement('div')
s.className = 'strip'
Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
// Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última
// columna). El reverso está girado sobre su centro: allí su izquierda es la derecha de la tira.
const l = fc > 0 ? OV : 0, rr = fc < N - 1 ? OV : 0
const face = (cls, src, col, off) => `<div class="sf ${cls}" style="left:${-l}px;right:${-rr}px">${src ? `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${off - col * w}px">` : ''}</div>`
s.innerHTML = face('front', frontSrc, fc, l) + face(backSrc ? 'back' : 'back paper', backSrc, bc, rr)
parent.append(s)
strips.push({ s, f: s.children[0], b: s.children[1] })
parent = s
}
const step = spineLeft ? w : -w
const { angles, span, solve } = curlCurve(N)
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
// tiltZ/tiltX: curvatura total de la hoja agarrada por arriba o por abajo. Se reparte entre las tiras salvo la
// del lomo, que no se curva, y el borde libre se lleva todo: la hoja sigue pegada al libro. Cambia de
// signo con la cara visible para que la parte agarrada vaya siempre por delante.
function set(g, reverse, tiltZ = 0, tiltX = 0) {
let prev = 0, lift = 0
angles(g, reverse).forEach((a, i) => {
const t = strips[i]
const c = Math.cos((a * Math.PI) / 180)
const bend = i && (tiltZ || tiltX) ? ` rotateZ(${((tiltZ * c) / (N - 1)).toFixed(3)}deg) rotateX(${((tiltX * c) / (N - 1)).toFixed(3)}deg)` : ''
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)${bend}`
prev = a
// Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari
// cada capa se compone aparte y en las uniones entre tiras asomaban líneas.
t.f.style.filter = `brightness(${(1 - (1 - c) * 0.3).toFixed(3)})`
t.b.style.filter = `brightness(${(1 - (1 + c) * 0.3).toFixed(3)})`
lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
})
return lift
}
// Punto fijo de la curvatura: el borde opuesto al agarrado (no afecta al giro sobre el eje vertical).
const pivot = y => strips.forEach(t => { t.s.style.transformOrigin = `${spineLeft ? 'left' : 'right'} ${y}` })
return { el, set, span, solve, pivot }
}
const GRAB_TILT_Z = 14, GRAB_TILT_X = 10 // curvatura total, en grados, de la hoja agarrada por arriba o por abajo
// drag: la página sigue al dedo; devuelve el control (move/release) en vez de animarse sola.
function turnPage(old, stage, dir, drag = false) {
// "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
const fitWidth = box.classList.contains('fit-width')
const pan = isPan(old)
let kind = pan ? 'pan' : settings.anim
const ready = im => im.complete && im.naturalWidth > 0
const oldImgs = [...old.querySelectorAll('img')]
// La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar.
if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide'
if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor
old.classList.add('leaving')
box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima
const double = oldImgs.length === 2 && shown.length === 2
// Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
// En doble página, el reverso de la hoja es una página nueva: sin cargar, se verían solo las sombras
// de las tiras (líneas oscuras).
if (kind === 'flip' && double && ![...stage.querySelectorAll('img')].every(ready)) kind = 'slide'
const extra = []
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' }
// Al soltar el dedo sin pasar la página: queda la vieja como estaba.
const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '', background: '' }) }
const easeOut = t => 1 - (1 - t) ** 3
// Tapa lo que hay a los lados de la página, para que las vecinas no aparezcan de golpe en una pantalla más ancha.
const addSideMasks = width => {
const side = (box.clientWidth - width) / 2
if (side <= 0.5) return
for (const edge of ['left', 'right']) {
const mask = document.createElement('i')
mask.className = 'panmask'
Object.assign(mask.style, { [edge]: '0', width: `${side}px` })
box.append(mask)
extra.push(mask)
}
}
// step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance.
const B = box.getBoundingClientRect()
const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
const grabOf = r => {
const at = grabY != null ? (grabY - B.top - r.t) / r.h : 0.5
return at < 1 / 3 ? -1 : at > 2 / 3 ? 1 : 0
}
const newIm = stage.querySelector('img')
let bookCurl = null
if (kind === 'flip' && ready(newIm) && isBookTurn(old, dir)) {
bookCurl = glCurl(rel(oldImgs[0]), oldImgs[0], newIm, sign > 0, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight })
}
let step, dur, range = box.clientWidth, toG = q => q, auto = easeOut, g0 = 0, g1 = 1
if (kind === 'fade') {
step = g => { old.style.opacity = String(1 - g) }
dur = 180
} else if (kind === 'slide') {
step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` }
dur = 260
} else if (kind === 'pan') {
// Las dos páginas van juntas, lomo con lomo: la nueva empuja a la vieja. Lo que se vea a los lados de la página
// queda tapado, para que la nueva no aparezca de golpe si la pantalla es más ancha que ella.
const oldW = oldImgs[0].getBoundingClientRect().width
const newW = ready(newIm) ? newIm.getBoundingClientRect().width : oldW
const shift = (oldW + newW) / 2
addSideMasks(Math.max(oldW, newW))
old.style.background = 'transparent'
step = g => { old.style.transform = `translateX(${-sign * shift * g}px)`; stage.style.transform = `translateX(${sign * shift * (1 - g)}px)` }
step(0)
dur = 460
range = shift
auto = t => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) ** 2)
} else if (bookCurl) {
// Como un libro: la hoja gira sobre el lomo y descubre la página que tiene debajo, la vista la acompaña y la
// nueva, en el reverso de la hoja, cae en su sitio. Alrededor están las páginas vecinas, lomo con lomo.
const rect = rel(oldImgs[0]), spineLeft = sign > 0
const hinge = spineLeft ? rect.l : rect.l + rect.w
const newRect = rel(newIm)
const travel = (spineLeft ? newRect.l + newRect.w : newRect.l) - hinge
const from = Number(oldImgs[0].dataset.i), to = shown[0]
const under = []
const neighbour = (i, onHingeSide) => {
if (i < 0 || i >= total || wide.has(i)) return
const im = preloaded.get(i) || Object.assign(new Image(), { src: pageSrc(i) })
const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0
const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h
im.className = 'leafkeep'
im.alt = ''
im.dataset.gutter = spineLeft === onHingeSide ? 'l' : 'r'
place(im, { l: spineLeft === onHingeSide ? hinge : hinge - w, t: (box.clientHeight - h) / 2, w, h })
im.style.willChange = 'transform'
under.push(im)
}
const back = document.createElement('i')
back.className = 'leafkeepback'
place(back, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight })
neighbour(to + dir, true)
neighbour(from - dir, false)
box.append(back, ...under, bookCurl.el)
extra.push(back, ...under, bookCurl.el)
addSideMasks(Math.max(rect.w, newRect.w))
old.style.visibility = 'hidden'
const grab = grabOf(rect)
if (grab) bookCurl.pivot(grab < 0 ? 'bottom' : 'top')
step = g => {
const follow = Math.min(1, Math.max(0, (g - BOOK_FOLLOW_FROM) / (1 - BOOK_FOLLOW_FROM)))
const cam = travel * follow * follow * (3 - 2 * follow)
for (const im of under) im.style.transform = `translateX(${cam}px)`
const k = grab ? grab * Math.sin(Math.PI * g) : 0
bookCurl.set(g, false, (spineLeft ? 1 : -1) * GRAB_TILT_Z * k, GRAB_TILT_X * k, cam)
}
step(0)
dur = 850
auto = t => t
range = rect.w
toG = q => bookCurl.solve(1 - q * 2, false)
} else {
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
// Medidas relativas al contenedor.
const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
let rect, paper, frontIm, backIm = null, spineLeft, reverse = false
if (double) {
// Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
// encima de la nueva) hasta que la hoja cae.
const oldX = byX(oldImgs)
// Las nuevas puede que aún no estén cargadas (sin tamaño ni posición): su lado sale del orden de lectura.
const newX = [...stage.querySelectorAll('img')]
if (settings.dir === 'rtl') newX.reverse()
const front = sign > 0 ? oldX.at(-1) : oldX[0]
const stay = sign > 0 ? oldX[0] : oldX.at(-1)
rect = rel(front)
const other = rel(stay)
const left = Math.min(rect.l, other.l), top = Math.min(rect.t, other.t)
paper = { l: left, t: top, w: Math.max(rect.l + rect.w, other.l + other.w) - left, h: Math.max(rect.t + rect.h, other.t + other.h) - top }
frontIm = front
backIm = sign > 0 ? newX[0] : newX.at(-1)
spineLeft = sign > 0
const keep = document.createElement('img')
keep.className = 'leafkeep'
keep.dataset.gutter = stay.dataset.gutter
keep.src = stay.src
place(keep, rel(stay))
// Su hendidura es transparente: sin negro detrás, se vería la página nueva y la hendidura se aclararía.
const keepBack = document.createElement('i')
keepBack.className = 'leafkeepback'
place(keepBack, rel(stay))
box.append(keepBack, keep)
extra.push(keepBack, keep)
old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar
} else {
// Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la
// página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo
// gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa.
const img = dir > 0 ? oldImgs[0] : stage.querySelector('img')
rect = rel(img)
frontIm = img
spineLeft = settings.dir !== 'rtl'
reverse = dir < 0
if (dir > 0) old.style.visibility = 'hidden'
}
const grab = grabOf(rect)
const curl = glCurl(rect, frontIm, backIm, spineLeft, paper) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab)
// Sin WebGL, la sombra de debajo es un degradado desde el lomo.
let drop = null
if (!curl.gl) {
place(curl.el, rect)
drop = document.createElement('i')
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
place(drop, rect)
box.append(drop)
extra.push(drop)
}
box.append(curl.el)
extra.push(curl.el)
;[g0, g1] = double ? [0, 1] : curl.span(reverse)
// Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva
// sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta.
// Por el centro, como siempre.
if (grab) curl.pivot(grab < 0 ? 'bottom' : 'top')
step = g => {
const k = grab ? grab * Math.sin(Math.PI * Math.min(1, Math.max(0, (g - g0) / (g1 - g0 || 1)))) : 0
const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k)
if (drop) drop.style.opacity = (lift * 0.8).toFixed(3)
}
dur = double ? 750 : 610
auto = t => g0 + (g1 - g0) * t
// El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble);
// hacia atrás en una página, del lomo a su sitio.
range = double ? 2 * rect.w : rect.w
toG = q => curl.solve(reverse ? q : 1 - q * (double ? 2 : 1), reverse)
}
if (drag) {
let g = toG(0)
step(g)
const ctl = {
anims: [], cleanup, range, pan,
move(q) { g = toG(Math.min(1, Math.max(0, q))); step(g) },
// Termina de pasar la página (commit) o la devuelve a su sitio; done(commit) al acabar.
release(commit, done) {
const from = g, to = commit ? g1 : g0
const a = frameAnim(Math.max(120, (dur * Math.abs(to - from)) / (g1 - g0 || 1)), t => { g = from + (to - from) * easeOut(t); step(g) })
ctl.anims = [a]
a.finished.then(() => {
if (turning !== ctl) return
turning = null
if (commit) cleanup()
else revert()
done(commit)
}, () => {})
},
}
turning = ctl
return
}
const anims = [frameAnim(dur, t => step(auto(t)))]
turning = { anims, cleanup }
anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
}
function showEnd(dir = 0) {
finishTurn()
showSeq++ // descarta una página que estuviera esperando a cargar
clearTimeout(waitTimer)
box.classList.remove('waiting')
atEnd = true
resetZoom()
box.innerHTML = endBlock
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(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
const wasEnd = atEnd
atEnd = false
shown = g
page = g[0]
setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0)
if (changed) scheduleSave()
return
}
n = Math.min(Math.max(n, 0), total - 1)
const changed = n !== page
page = n
shown = [n]
setPageLabel()
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
if (changed) scheduleSave()
}
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return }
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
// En der → izq, la barra de abajo va como la lectura: el capítulo siguiente a la izquierda.
function syncDirUi() {
const rtl = isPaged() && settings.dir === 'rtl'
slider.dir = rtl ? 'rtl' : 'ltr'
$('.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 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'
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()
if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página
resetZoom()
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()
if (isPaged()) {
box.innerHTML = ''
atEnd = false
shown = []
goTo(keep)
return
}
page = keep
shown = [keep]
// Las páginas de un libro tienen tamaño conocido: no hace falta cargar las anteriores para saltar a la guardada.
const near = i => (book ? Math.abs(i - page) <= 2 : i < page + 3)
box.innerHTML = Array.from({ length: total }, (_, i) => `<img data-i="${i}" ${near(i) ? `src="${pageSrc(i)}"` : 'data-lazy="1"'} ${book ? `style="aspect-ratio:${book.W}/${book.H}"` : ''} alt="">`).join('') + endBlock
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
if (!e.isIntersecting || !e.target.dataset.lazy) return
delete e.target.dataset.lazy
const i = Number(e.target.dataset.i)
if (book) book.whenReady(i, url => { if (!e.target.dataset.lazy) e.target.src = url })
else e.target.src = pageSrc(i)
}), { rootMargin: '2000px 0px' })
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
let ready = false
observer = new IntersectionObserver(entries => {
if (!ready) return
for (const e of entries) if (e.isIntersecting) goTo(Number(e.target.dataset.i), true)
}, { rootMargin: '-45% 0px -45% 0px' })
const imgs = [...box.querySelectorAll('img')]
imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
const lazyDisconnect = observer.disconnect.bind(observer)
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
const before = (book ? [] : imgs.slice(0, page + 1)).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
imgs[page]?.scrollIntoView()
lastY = window.scrollY
setTimeout(() => (ready = true), 300)
})
setPageLabel()
}
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
const zoom = { s: 1, x: 0, y: 0 }
const stageEl = () => $('.stage:not(.leaving)', box)
function applyZoom() {
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() {
const st = stageEl()
if (!st) return
const W = st.offsetWidth, H = st.offsetHeight
zoom.x = Math.min(0, Math.max(W - W * zoom.s, zoom.x))
zoom.y = Math.min(0, Math.max(H - H * zoom.s, zoom.y))
}
// (px, py): punto en coordenadas del contenedor que queda fijo al cambiar la escala.
function zoomAt(s, px, py) {
s = Math.min(5, Math.max(1, s))
const cx = (px - zoom.x) / zoom.s, cy = (py - zoom.y) / zoom.s
zoom.s = s
zoom.x = px - cx * s
zoom.y = py - cy * s
if (s === 1) { zoom.x = 0; zoom.y = 0 }
clampPan()
applyZoom()
}
function resetZoom() { zoom.s = 1; zoom.x = 0; zoom.y = 0; applyZoom() }
const local = (cx, cy) => { const r = box.getBoundingClientRect(); return [cx - r.left, cy - r.top] }
const toggleUi = () => { reader.classList.toggle('hide-ui'); if (reader.classList.contains('hide-ui')) $('.rpanel', reader).hidden = true }
const pts = new Map()
let gesture = null, tap = null, lastTap = { t: 0, x: 0, y: 0 }, uiTimer = null
function onTap(cx, cy) {
const now = Date.now()
const dbl = now - lastTap.t < 300 && Math.hypot(cx - lastTap.x, cy - lastTap.y) < 40
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 && !atEnd && (center || zoom.s > 1)) {
clearTimeout(uiTimer)
if (zoom.s > 1) resetZoom()
else zoomAt(2.5, ...local(cx, cy))
return
}
if (zoom.s > 1) return // con zoom, un toque no pasa página
if (center) { clearTimeout(uiTimer); uiTimer = setTimeout(toggleUi, 280); return }
// La hoja se levanta por la altura del toque, igual que al arrastrarla.
grabY = cy
try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null }
}
box.addEventListener('pointerdown', e => {
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: 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 }
tap = null
}
})
box.addEventListener('pointermove', e => {
if (!pts.has(e.pointerId)) return
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (gesture?.type === 'turn') {
if (turning !== gesture.ctl) return
const d = (e.clientX - gesture.x0) * gesture.k
gesture.v = (d - gesture.d) / Math.max(1, e.timeStamp - gesture.t)
gesture.d = d
gesture.t = e.timeStamp
gesture.ctl.move(d / gesture.ctl.range)
return
}
if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true
// Arrastre horizontal con un dedo: la página empieza a pasar y sigue al dedo.
if (tap && !gesture && pts.size === 1 && zoom.s === 1 && e.pointerType !== 'mouse' && settings.anim !== 'none') {
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 ? !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, 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)
}
tap = null
}
}
}
if (gesture?.type === 'pinch' && pts.size >= 2) {
const [a, b] = [...pts.values()]
const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
zoom.x += mx - gesture.mx
zoom.y += my - gesture.my
zoomAt(zoom.s * (d / gesture.d), ...local(mx, my))
Object.assign(gesture, { d, mx, my })
} else if (gesture?.type === 'pan') {
zoom.x += e.clientX - gesture.x
zoom.y += e.clientY - gesture.y
gesture.x = e.clientX
gesture.y = e.clientY
clampPan()
applyZoom()
}
})
const endPointer = e => {
if (!pts.delete(e.pointerId)) return
if (gesture?.type === 'turn') {
// Se pasa si se ha llevado más de un 30 % o se ha soltado con impulso hacia delante.
const g = gesture
gesture = null
tap = null
if (turning !== g.ctl) return
const q = g.d / g.ctl.range
const commit = e.type === 'pointerup' && (g.v > 0.35 || (q > 0.3 && g.v > -0.15))
if (commit && !g.ctl.pan) turnSound(settings.sfx, turnPan(g.dir))
g.ctl.release(commit, ok => {
if (ok) return
shown = g.prev
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
}
if (pts.size === 1 && zoom.s > 1) {
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 && 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 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
}
gesture = null
tap = null
}
}
box.addEventListener('pointerup', endPointer)
box.addEventListener('pointercancel', endPointer)
box.addEventListener('wheel', e => {
if (!isPaged() || atEnd || !e.ctrlKey) return // Ctrl+rueda o pellizco del trackpad
e.preventDefault()
zoomAt(zoom.s * Math.exp(-e.deltaY / 200), ...local(e.clientX, e.clientY))
}, { passive: false })
// En modo vertical el zoom es el nativo del navegador; un toque muestra/oculta las barras.
box.addEventListener('click', e => {
if (isPaged() || e.target.closest('a,button')) return
reader.classList.toggle('hide-ui')
})
$('#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', { 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))
}
// En un libro, volver a maquetar reabre el lector: antes hay que esperar a que el modo nuevo esté guardado.
const savePrefsThenRender = () => { const saved = savePrefs(); if (book) saved.finally(render); else render() }
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefsThenRender() })
$('#modequick').addEventListener('click', () => {
settings.mode = isPaged() ? 'vertical' : lastPaged
savePrefsThenRender()
})
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
$('#dir').addEventListener('change', e => {
settings.dir = e.target.value
box.dataset.dir = settings.dir
refreshGutters()
syncDirUi()
savePrefs()
})
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
$('#anim').value = settings.anim
$('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
$('#sfx').value = SOUND_LEVELS[settings.sfx] ? settings.sfx : 'mid'
$('#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, 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 => {
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 (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 && 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))
})
}
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')
b.disabled = true
b.innerHTML = `${icon('phone')} Guardando…`
const done = () => { if (offHas(chapterId) && b.isConnected) { b.innerHTML = `${icon('phone')} En el dispositivo`; offState.listeners.delete(done) } }
offState.listeners.add(done)
offEnqueue([{ mangaId, mangaTitle: manga.title, cover: manga.cover, lang, chapterId, label: chapLabel(chapter), num: chapter.num }])
})
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
// --- Recortes: se arrastra un rectángulo sobre la página y se ajusta con los puntos de sus esquinas. Se recorta
// del original (no de la versión HD) y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas
// del documento: al llevar un punto al borde de la pantalla la lectura se desplaza y la zona sigue creciendo, y
// en lectura vertical puede abarcar varias páginas, que se unen en una sola imagen.
const CROP_MAX_SIDE = 8000 // el mismo límite que en el servidor (clips.js)
const CROP_MAX_AREA = 16e6 // por encima, Safari en iOS puede no generar el canvas
let crop = null
function endCrop() {
if (!crop) return
cancelAnimationFrame(crop.raf)
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')
ov.className = 'cropov'
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} <em>Arrastra sobre el panel que quieres guardar</em></span>
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
<div class="crop-rect" hidden>${['nw', 'ne', 'sw', 'se'].map(h => `<i class="crop-h" data-h="${h}"></i>`).join('')}</div>`
reader.append(ov)
$('#clipbtn').classList.add('on')
const rectEl = $('.crop-rect', ov), saveBtn = $('[data-a=save]', ov), hint = $('.crop-bar em', ov)
crop = { ov, r: null, start: null, prev: null, pan: null, pid: null, off: { x: 0, y: 0 }, last: null, raf: 0 }
crop.draw = () => {
const r = crop?.r
if (!r) return
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
rectEl.hidden = false
}
const canScroll = () => document.documentElement.scrollHeight > innerHeight + 1
// Lleva la esquina que se arrastra hasta el dedo; la opuesta (crop.start) se queda fija.
const stretch = () => {
const x = crop.last.x + scrollX + crop.off.x, y = crop.last.y + scrollY + crop.off.y
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
crop.draw()
}
// Con el dedo cerca del borde de arriba o de abajo, la lectura se desplaza y la zona sigue creciendo.
const EDGE = 80, EDGE_SPEED = 22
const autoScroll = () => {
if (!crop?.start) return
const y = crop.last.y
const v = y < EDGE ? -(EDGE - y) / EDGE : y > innerHeight - EDGE ? (y - innerHeight + EDGE) / EDGE : 0
if (v && crop.r) {
scrollBy(0, v * EDGE_SPEED)
stretch()
}
crop.raf = requestAnimationFrame(autoScroll)
}
window.addEventListener('scroll', crop.draw, { passive: true })
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
ov.addEventListener('pointerdown', e => {
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0) || crop.pid != null) return
const x = e.clientX + scrollX, y = e.clientY + scrollY, r = crop.r
const handle = e.target.closest('.crop-h')?.dataset.h
crop.pid = e.pointerId
ov.setPointerCapture(e.pointerId)
// Con el dedo la lectura no se puede desplazar de otro modo: deslizar dentro de la zona la mueve, para llegar
// a las esquinas que quedan fuera de la pantalla.
if (!handle && e.pointerType !== 'mouse' && r && canScroll() && x >= r.l && x <= r.r && y >= r.t && y <= r.b) {
crop.pan = e.clientY
return
}
if (handle) {
const cx = handle[1] === 'w' ? r.l : r.r, cy = handle[0] === 'n' ? r.t : r.b
crop.start = { x: handle[1] === 'w' ? r.r : r.l, y: handle[0] === 'n' ? r.b : r.t }
crop.off = { x: cx - x, y: cy - y }
crop.prev = r
} else {
crop.start = { x, y }
crop.off = { x: 0, y: 0 }
crop.prev = null
crop.r = null
rectEl.hidden = true
saveBtn.disabled = true
}
crop.last = { x: e.clientX, y: e.clientY }
crop.raf = requestAnimationFrame(autoScroll)
})
ov.addEventListener('pointermove', e => {
if (e.pointerId !== crop.pid) return
if (crop.pan != null) {
scrollBy(0, crop.pan - e.clientY)
crop.pan = e.clientY
return
}
if (!crop.start) return
crop.last = { x: e.clientX, y: e.clientY }
stretch()
})
const up = e => {
if (!crop || e.pointerId !== crop.pid) return
crop.pid = null
crop.pan = null
if (!crop.start) return
cancelAnimationFrame(crop.raf)
crop.start = null
// Demasiado pequeña: al ajustar vuelve a como estaba; al marcarla de cero, no queda nada.
if (!crop.r || crop.r.r - crop.r.l < 12 || crop.r.b - crop.r.t < 12) crop.r = crop.prev
if (crop.r) crop.draw()
else rectEl.hidden = true
saveBtn.disabled = !crop.r
if (crop.r) hint.textContent = canScroll() ? 'Mueve los puntos; llévalos al borde para seguir' : 'Ajusta la zona con los puntos'
}
ov.addEventListener('pointerup', up)
ov.addEventListener('pointercancel', up)
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
saveBtn.addEventListener('click', e => saveCrop(e.target.closest('button')))
}
// Las imágenes bajo el rectángulo y la zona de cada una, en píxeles de pantalla desde su esquina. Por páginas, solo
// la de mayor intersección; en vertical, todas las que toca, de arriba abajo.
function cropParts(r) {
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
const hits = []
for (const img of box.querySelectorAll('img')) {
const b = img.getBoundingClientRect()
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
if (w <= 0 || h <= 0) continue
hits.push({ img, b, area: w * h, x0: Math.max(c.l, b.left) - b.left, y0: Math.max(c.t, b.top) - b.top, x1: Math.min(c.r, b.right) - b.left, y1: Math.min(c.b, b.bottom) - b.top })
}
const ready = hit => hit.img.complete && hit.img.naturalWidth > 0
if (isPaged()) {
const loaded = hits.filter(ready)
return loaded.length ? [loaded.reduce((a, h) => (h.area > a.area ? h : a))] : []
}
if (!hits.every(ready)) throw new Error('Espera a que carguen las páginas de la zona')
return hits
}
async function saveCrop(btn) {
btn.disabled = true
try {
const parts = crop?.r ? cropParts(crop.r) : []
if (!parts.length) throw new Error('Marca una zona sobre la página')
// Del original: el servidor lo reescala él mismo.
const sources = await Promise.all(parts.map(({ img }) => new Promise((resolve, reject) => {
const u = new URL(img.currentSrc || img.src, location.href)
u.searchParams.delete('hd')
const im = new Image()
im.onload = () => resolve(im)
im.onerror = () => reject(new Error('No se pudo cargar la página'))
im.src = u.href
})))
// Misma escala para todas (la de la página con más detalle), una debajo de otra y sin los huecos de la lectura.
const scale = Math.max(...parts.map((h, i) => sources[i].naturalWidth / h.b.width))
const left = Math.min(...parts.map(h => h.b.left + h.x0)), right = Math.max(...parts.map(h => h.b.left + h.x1))
const fullW = (right - left) * scale, fullH = parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * scale
if (fullW < 16 || fullH < 16) throw new Error('La zona es demasiado pequeña')
const k = scale * Math.min(1, CROP_MAX_SIDE / Math.max(fullW, fullH), Math.sqrt(CROP_MAX_AREA / (fullW * fullH)))
const cv = document.createElement('canvas')
cv.width = Math.round((right - left) * k)
cv.height = Math.round(parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * k)
const ctx = cv.getContext('2d')
ctx.imageSmoothingQuality = 'high'
if (parts.length > 1) { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height) } // páginas de distinto ancho
let y = 0
parts.forEach((h, i) => {
const src = sources[i], fx = src.naturalWidth / h.b.width, fy = src.naturalHeight / h.b.height
const top = Math.round(y * k)
y += h.y1 - h.y0
ctx.drawImage(src, Math.round(h.x0 * fx), Math.round(h.y0 * fy), Math.max(1, Math.round((h.x1 - h.x0) * fx)), Math.max(1, Math.round((h.y1 - h.y0) * fy)),
Math.round((h.b.left + h.x0 - left) * k), top, Math.round((h.x1 - h.x0) * k), Math.round(y * k) - top)
})
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 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}`)
endCrop()
toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD')
} catch (err) {
toast(err.message)
btn.disabled = false
}
}
$('#clipbtn').addEventListener('click', startCrop)
const onKey = e => {
if (crop) { if (e.key === 'Escape') endCrop(); return }
if (!isPaged() || e.target.matches('input,select')) return
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
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()
}
document.addEventListener('keydown', onKey)
let lastY = 0
const onScroll = () => {
if (settings.mode !== 'vertical') return
const y = window.scrollY
if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y }
}
window.addEventListener('scroll', onScroll, { passive: true })
// Al girar la tablet o redimensionar, rehacer la agrupación de doble página.
let resizeTimer = null
const onResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
if (book && sizeChanged()) return reopenBook()
if (!isPaged() || atEnd) return
box.classList.toggle('double', isDouble())
goTo(lastShown(), false, true)
}, 200)
}
window.addEventListener('resize', onResize)
if (book) {
const opts = bookSettings()
for (const [id, key] of [['bsize', 'size'], ['bfont', 'font'], ['bmargin', 'margin'], ['bpaper', 'paper']]) {
$(`#${id}`).value = String(opts[key])
$(`#${id}`).addEventListener('change', e => {
store.set('bookSettings', { ...bookSettings(), [key]: key === 'size' ? Number(e.target.value) : e.target.value })
reopenBook()
})
}
}
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()
observer?.disconnect()
clearTimeout(uiTimer)
document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('pagehide', beacon)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onResize)
}
}
// ---------- Recortes ----------
const clipStatus = c => c.status === 'done' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
async function viewClips(params) {
const mangaId = params.get('manga')
const here = () => location.hash.split('?')[0] === '#/clips'
app.innerHTML = '<div class="loading">Cargando…</div>'
let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`)
if (!here()) return
const total = data.mangas.reduce((a, m) => a + m.n, 0)
app.innerHTML = `<h1>${icon('scissors')} Recortes</h1>
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
<div id="cgrid" class="clips"></div>
<div id="lb" class="lightbox" hidden></div>`
app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' }))
const grid = $('#cgrid'), lb = $('#lb')
let open = -1, showHd = true
function renderGrid() {
grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
<figure class="clip" data-i="${i}">
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
</figure>`).join('')
: `<p class="muted">Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.</p>`
grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i))))
}
function openClip(i) {
open = i
const c = data.clips[i]
if (!c) return closeClip()
const src = c.hd && showHd ? c.hd : c.img
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
lb.innerHTML = `
<div class="lb-img${c.height / c.width > 2.5 ? ' long' : ''}"><img src="${src}" alt=""></div>
<div class="lb-side">
<div class="row" style="justify-content:space-between">
<span class="muted">${i + 1} / ${data.clips.length}</span>
<span class="row" style="gap:6px">
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
</span>
</div>
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
<div class="row" style="gap:6px;flex-wrap:wrap">
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
</div>
</div>`
lb.hidden = false
document.body.classList.add('noscroll')
lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1))
lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1))
lb.querySelector('[data-a=close]').addEventListener('click', closeClip)
$('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) })
$('#note', lb).addEventListener('change', async e => {
try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) }
})
lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => {
try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) }
})
lb.querySelector('[data-a=del]').addEventListener('click', async () => {
if (!confirm('¿Eliminar este recorte?')) return
try {
await api(`/api/clips/${c.id}`, { method: 'DELETE' })
data.clips.splice(open, 1)
renderGrid()
data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip()
} catch (err) { toast(err.message) }
})
}
function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') }
lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() })
const onKey = e => {
if (open < 0 || e.target.matches('input')) return
if (e.key === 'Escape') closeClip()
else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1)
else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1)
}
document.addEventListener('keydown', onKey)
// Mientras haya recortes en cola, refrescar su estado.
let timer = null
function poll() {
clearTimeout(timer)
if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return
timer = setTimeout(async () => {
if (!here()) return
const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null)
if (!fresh || !here()) return
const key = d => d.clips.map(c => `${c.id}:${c.status}`).join()
if (key(fresh) !== key(data)) {
const openId = data.clips[open]?.id
data = fresh
renderGrid()
if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() }
}
poll()
}, 3000)
}
renderGrid()
poll()
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
}
// ---------- Librería ----------
// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias.
const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="card hcard" href="${href}">
<img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback>
${badge ? `<span class="badge corner">${badge}</span>` : ''}
${del ? `<button class="hdel" ${del.attrs} title="${esc(del.title)}" aria-label="${esc(del.title)}">${icon(del.icon || 'trash')}</button>` : ''}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${pct || 0}%"></i></div>
<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
if (!SHELF_OK) document.querySelector('#topbar nav a[href="#/shelf"]')?.remove()
// 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)
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) }
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()
setTimeout(wait, 60)
}
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
return (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => new Promise(r => requestAnimationFrame(() => { setTimeout(wait, 120); r() }))) // para entonces ya se está en el lector
}
// Lo último que se supo del estante, en este dispositivo: con ello se monta al momento (sin esperar a cada obra)
// y sin conexión. Tomos y logos se refrescan luego, y solo se rehace lo que cambie.
const shelfSnapKey = () => `shelfSnap:${me?.username || ''}`
const LOGO_DAYS = { found: 30, none: 7 }
const shelfSort = (works, order) => order === 'az'
? [...works].sort((a, b) => String(a.title).replace(/^[^\p{L}\p{N}]+/u, '').localeCompare(String(b.title).replace(/^[^\p{L}\p{N}]+/u, ''), 'es', { sensitivity: 'base', numeric: true }))
: [...works].sort((a, b) => (b.last || 0) - (a.last || 0))
async function viewShelf(params) {
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
let order = store.get('shelfOrder', 'recent')
const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`)
app.innerHTML = `<div id="shelf3d"></div><div class="shelftools"><a class="btn small" href="#/library">${icon('back')} Librería</a><button class="btn small" id="shelforder" type="button" title="Cambiar el orden"></button></div>
<p class="shelfnote" id="shelfnote"></p>
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
const note = $('#shelfnote'), card = $('#shelfpeek'), orderBtn = $('#shelforder')
orderBtn.innerHTML = orderLabel()
const say = (text, ms) => { clearTimeout(note.t); note.textContent = text; note.hidden = !text; if (ms) note.t = setTimeout(() => { note.hidden = true }, ms) }
const focusOn = shelfTrip?.mangaId || params?.get('focus') || null // al volver del lector, o desde la ficha
shelfTrip = null
let shelf = null, gone = false, offline = !serverReachable
const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() }
document.addEventListener('keydown', onKey)
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); clearTimeout(snapTimer); saveSnap(); shelf?.destroy(); shelf = null }
// Foto del estante en el dispositivo.
const snap = store.get(shelfSnapKey(), null)
const logos = snap?.logos || {} // id -> { url, at }
let works = [], snapTimer = 0
const saveSnap = () => { if (works.length && !offline) store.set(shelfSnapKey(), { at: Date.now(), works, logos }) }
const saveSoon = () => { clearTimeout(snapTimer); snapTimer = setTimeout(saveSnap, 1500) }
// Sin conexión, cada tomo se lee por los capítulos guardados en el dispositivo.
const onDevice = id => !offline || offHas(id)
// Vistazo: lo que se sabe del tomo sacado, junto a él.
const readOf = info => {
const { work, vol, state } = info
if (offline) {
if (state === 'reading' && work.chapterId && offHas(work.chapterId) && (!vol.chapters?.length || vol.chapters.includes(work.chapterId))) return readUrl(work.id, work.chapterId, work.page || 0)
const here = (vol.chapters || []).find(offHas)
return here ? readUrl(work.id, here) : null
}
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)
if (!href) return say('Este tomo no está guardado en el dispositivo.', 2500)
const go = async snap => {
if (gone) return
if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id }
if (snap) await handoffCover(snap) // la foto ya está pintada antes de quitar el estante: no se ve nada entre medias
location.hash = href
}
leaving = true
if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null)
}
// El tomo sacado lleva su nota, como la de la balda, con lo que se sabe de él.
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 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`
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>`
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 || ''
let shelfMod = null
// Lo guardado en el dispositivo, para cuando no hay servidor.
const offlineWorks = () => {
const known = new Map((snap?.works || []).map(w => [w.id, w]))
return offByManga().filter(m => !isBookId(m.mangaId)).map(m => known.get(m.mangaId) || { id: m.mangaId, title: m.title, cover: m.cover, last: m.at, chapterId: null, page: 0, lang: m.chapters[0]?.lang || null })
}
const start = (list, lib) => {
works = list
shelf = shelfMod.createShelf3D($('#shelf3d'), lib, {
works: shelfSort(works, order),
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: offline ? null : 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)
for (const w of works) if (logos[w.id]?.url) shelf.setLogo(w.id, logos[w.id].url)
}
orderBtn.addEventListener('click', () => {
order = order === 'az' ? 'recent' : 'az'
store.set('shelfOrder', order)
orderBtn.innerHTML = orderLabel()
shelf?.unpeek()
shelf?.setWorks(shelfSort(works, order))
shelf?.toTop()
})
try {
const [lib, mod] = await Promise.all([import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
shelfMod = mod
if (gone) return
mod.loadNoteFonts()
// Con foto guardada, el mueble sale ya; la lista del servidor llega después.
if (snap?.works?.length && !offline) start(snap.works.filter(w => wantsId(w.id)), lib)
else if (!snap?.works?.length && !offline) say('Colocando los tomos…')
let fresh = null
if (!offline) fresh = await api('/api/shelf').catch(e => { if (e.message === NO_SERVER || !serverReachable) offline = true; else throw e; return null })
if (gone) return
if (offline) {
const list = offlineWorks()
if (shelf) { works = list; shelf.setWorks(shelfSort(works, order)) } else if (list.length) start(list, lib)
return say(list.length ? 'Sin conexión: solo lo guardado en este dispositivo.' : 'Sin conexión, y no hay tomos guardados en este dispositivo.', list.length ? 4000 : 0)
}
// La lista del servidor manda; de la foto se conservan los tomos ya conocidos.
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 })
if (!shelf) start(next, lib)
else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) }
else works = next.map(w => had.get(w.id))
saveSoon()
// Los tomos llegan aparte, de pocos en pocos: primero las obras que aún no tienen, luego se repasan las demás.
const queue = [...works.filter(w => !w.vols), ...works.filter(w => w.vols)]
let pending = queue.filter(w => !w.vols).length
if (!pending) say('')
const worker = async () => {
while (queue.length && !gone) {
const w = queue.shift(), first = !w.vols
const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
if (gone) return
const vols = r?.volumes?.length ? r.volumes : null
if (vols && JSON.stringify(vols) !== JSON.stringify(w.vols || null)) { w.vols = vols; shelf.setVolumes(w.id, vols); saveSoon() }
if (first && --pending <= 0) say('')
}
}
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.
const day = 864e5, now = Date.now()
const logoQueue = works.filter(w => { const l = logos[w.id]; return !l || now - (l.at || 0) > (l.url ? LOGO_DAYS.found : LOGO_DAYS.none) * day })
const logoWorker = async () => {
while (logoQueue.length && !gone) {
const w = logoQueue.shift()
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => undefined)
if (gone) return
if (r === undefined) continue // error: se vuelve a preguntar en la próxima visita
logos[w.id] = { url: r.logo || null, at: Date.now() }
if (r.logo) shelf.setLogo(w.id, r.logo)
saveSoon()
}
}
for (let k = 0; k < 2; k++) logoWorker()
} catch (e) {
if (!gone) say(`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>'
const [hist, fol, lists] = serverReachable
? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
: [null, null, []]
if (!location.hash.startsWith('#/library')) return
const online = !!hist
const dev = OFFLINE_OK ? offByManga().filter(m => wantsId(m.mangaId)) : []
const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id))
const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id))
const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id))
const tabs = [
['reading', 'Leyendo', 'book', reading.length],
['following', 'Siguiendo', 'bell', follows.length],
['finished', 'Terminados', 'check', finished.length],
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
...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 = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('shelf')} 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>
<button class="btn small" id="lren">${icon('pencil')} Renombrar</button><button class="btn small danger" id="ldel">${icon('trash')} Eliminar lista</button></div>` : ''}
<div id="libbody"><div class="loading">Cargando…</div></div>`
app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` }))
const body = $('#libbody')
const grid = (cards, empty) => (cards.length ? `<div class="grid">${cards.join('')}</div>` : `<p class="muted">${empty}</p>`)
const histCard = h => libCard({
href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title,
pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0,
badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '',
sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`,
del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' },
})
if (!online && tab !== 'device') {
body.innerHTML = `<p class="muted">Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}</p>`
+ (dev.length ? `<p><a class="btn" href="#/library?tab=device">${icon('phone')} En el dispositivo</a></p>` : '')
} else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.')
else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.')
else if (tab === 'following') {
body.innerHTML = grid(follows.map(f => libCard({
href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '',
sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún',
del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' },
})), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.')
} else if (tab === 'device') {
const total = dev.reduce((n, m) => n + m.bytes, 0)
body.innerHTML = (dev.length ? `<p class="muted small">${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.</p>` : '')
+ grid(dev.map(m => {
const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0))
return libCard({
href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title,
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
})
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
} else if (curList) {
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
body.innerHTML = grid(l.items.filter(it => wantsId(it.manga_id)).map(it => libCard({
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
} else body.innerHTML = '<p class="muted">Esa lista ya no existe.</p>'
const reload = () => viewLibrary(params)
body.addEventListener('click', async e => {
const b = e.target.closest('button.hdel')
if (!b) return
e.preventDefault()
e.stopPropagation()
try {
if (b.dataset.delhist) {
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' })
} else if (b.dataset.unfollow) {
await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' })
toast('Ya no sigues este manga')
} else if (b.dataset.deldev) {
const m = dev.find(x => x.mangaId === b.dataset.deldev)
if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return
await offRemove(m.chapters.map(c => c.chapterId))
} else if (b.dataset.unlist) {
await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' })
}
reload()
} catch (err) { toast(err.message) }
})
$('#newlist')?.addEventListener('click', async () => {
const name = prompt('Nombre de la nueva lista:')
if (!name?.trim()) return
try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) }
})
$('#lren')?.addEventListener('click', async () => {
const name = prompt('Nuevo nombre de la lista:', curList.name)
if (!name?.trim()) return
try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) }
})
$('#ldel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return
try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) }
})
}
// ---------- Descargas ----------
// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se
// emparejan por clave (una tarjeta que cambia de posición conserva su <img> y no recarga la portada).
function morph(from, to, childrenOnly = false) {
if (!childrenOnly) {
if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to }
if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from }
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name)
for (const { name, value } of [...to.attributes]) {
if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono
if (from.getAttribute(name) !== value) from.setAttribute(name, value)
}
}
const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n]))
const kids = [...to.childNodes]
kids.forEach((nk, i) => {
const key = nk.nodeType === 1 ? nk.dataset.key : null
const at = from.childNodes[i]
let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null
if (key) byKey.delete(key)
const node = old ? morph(old, nk) : nk
if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null)
})
while (from.childNodes.length > kids.length) from.lastChild.remove()
return from
}
async function viewQueues() {
if (cleanup) { cleanup(); cleanup = null }
const here = () => location.hash === '#/queues'
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
let q, list
try {
[q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
} catch (err) {
// Sin servidor: al menos la cola de este dispositivo (que sigue por su cuenta si hay red a medias).
if (!OFFLINE_OK || !here()) throw err
q = { downloads: [], upscale: { available: true, groups: [], current: null }, clips: { queued: 0, running: 0, errors: 0 }, covers: 0, offline: err.message }
list = []
}
if (!here()) return
const up = q.upscale
const library = list.filter(d => !DL_ACTIVE.includes(d.status))
const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0)
const total = list.reduce((n, d) => n + (d.size || 0), 0)
const extra = [
q.clips.queued + q.clips.running ? `<a href="#/clips">${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose</a>` : '',
q.clips.errors ? `<a href="#/clips" class="error">${plural(q.clips.errors, 'recorte')} con error</a>` : '',
q.covers ? `<span>${icon('image')} ${plural(q.covers, 'portada')} en cola</span>` : '',
].filter(Boolean)
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
<div class="card hcard qcard" data-key="${esc(key)}">
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback></a>
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
<a class="t" href="#/manga/${id}">${esc(title)}</a>
<div class="sub">${lines}</div>
${actions ? `<div class="qact">${actions}</div>` : ''}
</div>`
// Guardado en este dispositivo: lo hace el navegador, no el servidor (offState).
const devHtml = () => {
if (!OFFLINE_OK) return ''
const items = [...(offState.running ? [offState.running] : []), ...offState.queue]
const groups = new Map()
for (const it of items) {
const g = groups.get(it.mangaId) || { ...it, chapters: [] }
g.chapters.push(it)
groups.set(it.mangaId, g)
}
const saved = Object.values(offIdx())
const bytes = saved.reduce((n, c) => n + (c.bytes || 0), 0)
return `<h2>${icon('phone')} En este dispositivo ${items.length ? `<span class="badge">${items.length}</span>` : ''}</h2>
<p class="muted small">Capítulos que se guardan en este ${/Mobi|Android|iPhone/i.test(navigator.userAgent) ? 'móvil' : 'navegador'} para leer sin conexión; la cola sigue mientras la app esté abierta.
${saved.length ? `Guardados: <a href="#/library?tab=device">${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}</a>.` : ''}</p>
${groups.size ? `<div class="grid qgrid">${[...groups.values()].map((g, i) => {
const cur = offState.running?.mangaId === g.mangaId ? offState.running : null
const rest = g.chapters.length - (cur ? 1 : 0)
return qcard({
key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle,
pct: cur?.total ? (cur.done / cur.total) * 100 : null,
badge: cur ? icon('phone') : i + 1,
lines: `${cur ? `<div>${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}</div>` : ''}
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
})
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.</p>'}`
}
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
<section class="qsec">
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d),
badge: d.status === 'downloading' ? icon('download') : i + 1,
lines: `<div>${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%</div>
${d.status === 'downloading' ? `<div>${downloadDetail(d)}</div>` : ''}${d.scope === 'some' ? '<div>Capítulos elegidos</div>' : ''}`,
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
</section>
${OFFLINE_OK ? `<section class="qsec" id="devq">${devHtml()}</section>` : ''}
<section class="qsec">
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
${up.groups.length ? `${hdQueued ? `<p class="muted small">Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}</p>` : ''}
<div class="grid qgrid">${up.groups.map((g, i) => {
const cur = up.current?.mangaId === g.mangaId ? up.current : null
const rest = g.queued - (cur ? 1 : 0)
const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : ''
return qcard({
key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title,
pct: cur?.total ? (cur.pages / cur.total) * 100 : null,
badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!',
lines: `${cur ? `<div>Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}</div>` : ''}
${g.queued ? `<div>${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}</div><div>${fmtDur(g.eta)}</div>` : ''}
${g.errors.length ? `<div class="error" title="${esc(g.errors.map(e => `Cap. ${e.num ?? '?'}: ${e.error || 'error'}`).join('\n'))}">${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}</div>` : ''}`,
actions: `${g.errors.length ? `<button class="btn small" data-retry="${esc(g.mangaId)}" data-ids="${esc(g.errors.map(e => e.chapterId).join(' '))}">${icon('refresh')} Reintentar</button>` : ''}
${g.queued ? `<button class="btn small" data-hdstop="${esc(g.mangaId)}" title="Vacía su cola y desactiva el reescalado automático">${icon('stop')} Detener</button>` : ''}`,
})
}).join('')}</div>` : up.available ? '<p class="muted">No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.</p>' : ''}
${extra.length ? `<div class="facts muted">${extra.join('')}</div>` : ''}
</section>
<section class="qsec">
<h2>${icon('book')} Biblioteca descargada</h2>
<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,
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" ${d.shared ? 'data-shared="1"' : ''} title="${d.shared ? 'Quitar de tu biblioteca (otro usuario también lo tiene)' : `Eliminar la copia local (mangas/${esc(d.folder)})`}" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no tienes nada descargado. Usa “Descargar” en la ficha de un manga; lo que ya descargó otro usuario aparece aquí en cuanto lo empiezas a leer.'}</p>`}
</section>`
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
let wrap = $('#qwrap')
if (!wrap) {
app.innerHTML = `<div id="qwrap">${html}</div>`
wrap = $('#qwrap')
// Delegación: los botones se crean y actualizan en cada refresco.
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() }
wrap.addEventListener('click', e => {
const b = e.target.closest('button')
if (!b) return
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada')
else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido')
else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') }
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
else if (b.dataset.del) {
const shared = !!b.dataset.shared
if (!confirm(shared ? `¿Quitar «${b.dataset.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan.`
: `¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), shared ? 'Quitado de tu biblioteca' : 'Copia local eliminada')
}
})
} else {
const tpl = document.createElement('div')
tpl.innerHTML = html
morph(wrap, tpl, true)
}
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo).
const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers
const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000)
// El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página.
let raf = 0
const onDev = () => {
if (raf) return
raf = requestAnimationFrame(() => {
raf = 0
const box = $('#devq')
if (!box || !here()) return
const tpl = document.createElement('section')
tpl.innerHTML = devHtml()
morph(box, tpl, true)
})
}
offState.listeners.add(onDev)
cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) }
}
// ---------- Estadísticas ----------
const fmtNum = n => n.toLocaleString('es')
const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' })
const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) }
// Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra.
function barChart(data, { value, tip, label }, W = 560) {
const H = 190, L = 34, B = 24, T = 8
const max = niceMax(Math.max(1, ...data.map(value)))
const cw = (W - L) / data.length
const bw = Math.max(4, Math.min(28, cw - 6))
const y = v => T + (H - T - B) * (1 - v / max)
const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max]
const grid = ticks.map(v => `<line x1="${L}" x2="${W}" y1="${y(v)}" y2="${y(v)}" class="grid"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end" class="axis">${fmtNum(Math.round(v))}</text>`).join('')
const bars = data.map((d, i) => {
const v = value(d)
const x = L + i * cw + (cw - bw) / 2
const top = y(v), base = y(0), r = Math.min(4, bw / 2, base - top)
const bar = v > 0 ? `<path class="mark" d="M${x},${base}V${top + r}Q${x},${top} ${x + r},${top}H${x + bw - r}Q${x + bw},${top} ${x + bw},${top + r}V${base}Z"/>` : ''
const every = W < 420 ? 3 : 2
const lab = (data.length - 1 - i) % every ? '' : `<text x="${i === data.length - 1 ? x + bw : x + bw / 2}" y="${H - 6}" text-anchor="${i === data.length - 1 ? 'end' : 'middle'}" class="axis">${label(d)}</text>`
return `<g class="col" data-tip="${esc(tip(d))}"><rect x="${L + i * cw}" y="${T}" width="${cw}" height="${H - T - B}" class="hit"/>${bar}${lab}</g>`
}).join('')
return `<svg viewBox="0 0 ${W} ${H}" class="chart" role="img">${grid}${bars}</svg>`
}
async function viewStats() {
app.innerHTML = '<div class="loading">Cargando…</div>'
const st = await api('/api/stats')
const t = st.totals
if (!t.pages) {
app.innerHTML = '<h1>Estadísticas</h1><p class="muted">Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.</p>'
return
}
const maxDay = Math.max(1, ...st.calendar.map(d => d.pages))
const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5)))
const weekLabel = w => fmtDay(w.start)
const tile = (value, label, sub = '') => `<div class="tile"><div class="tv">${value}</div><div class="tl">${label}</div>${sub ? `<div class="ts">${sub}</div>` : ''}</div>`
// Calendario: columnas = semanas (lunes arriba).
const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7
const cells = [...Array(firstDow).fill('<i class="cell empty"></i>'), ...st.calendar.map(d =>
`<i class="cell l${level(d.pages)}" data-tip="${esc(`${fmtDay(d.day)}: ${d.pages ? `${fmtNum(d.pages)} págs.` : 'sin lectura'}${d.chapters ? ` · ${d.chapters} caps.` : ''}`)}"></i>`)].join('')
const gmax = Math.max(1, ...st.genres.map(g => g.pages))
app.innerHTML = `<h1>${icon('chart')} Estadísticas</h1>
<div class="tiles">
${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')}
${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')}
${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)}
${tile(fmtNum(t.chapters), 'Capítulos terminados')}
${tile(fmtNum(t.pages), 'Páginas leídas')}
${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)}
</div>
<div class="charts">
<section class="panel"><h3>Capítulos terminados por semana</h3><div class="chart-slot" data-chart="chapters"></div></section>
<section class="panel"><h3>Páginas leídas por semana</h3><div class="chart-slot" data-chart="pages"></div></section>
</div>
<section class="panel"><h3>Actividad (últimas 18 semanas)</h3>
<div class="heat-wrap"><div class="heat">${cells}</div></div>
<div class="heat-legend muted">Menos <i class="cell l0"></i><i class="cell l1"></i><i class="cell l2"></i><i class="cell l3"></i><i class="cell l4"></i><i class="cell l5"></i> Más páginas</div>
</section>
<div class="charts">
<section class="panel"><h3>Géneros favoritos</h3>
${st.genres.length ? `<div class="hbars">${st.genres.map(g => `<div class="hbar" data-tip="${esc(`${g.name}: ${fmtNum(g.pages)} págs. (${g.pct}% de lo leído)`)}">
<span class="hl">${esc(g.name)}</span><span class="ht"><i style="width:${(g.pages / gmax) * 100}%"></i></span><span class="hv">${g.pct}%</span></div>`).join('')}</div>
<p class="muted" style="font-size:.8rem">Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).</p>`
: '<p class="muted">Abre la ficha de los mangas que lees para registrar sus géneros.</p>'}
</section>
<section class="panel"><h3>Lo más leído</h3>
<div class="toplist">${st.topMangas.map((m, i) => `<a class="cont" href="#/manga/${m.id}"><span class="rank">${i + 1}</span>
<img src="${esc(m.cover || '/icon.svg')}" alt="" loading="lazy">
<div class="info"><b>${esc(m.title)}</b><div class="muted">${fmtNum(m.pages)} págs. · ${m.chapters} caps.</div></div></a>`).join('')}</div>
</section>
</div>
<details class="panel"><summary>Ver datos por semana</summary>
<table class="data"><thead><tr><th>Semana del</th><th>Capítulos</th><th>Páginas</th></tr></thead>
<tbody>${[...st.weeks].reverse().map(w => `<tr><td>${fmtDay(w.start)}</td><td>${w.chapters}</td><td>${fmtNum(w.pages)}</td></tr>`).join('')}</tbody></table>
</details>
<div id="tip" hidden></div>`
// Las gráficas se dibujan al ancho real del panel para que el texto no encoja en el móvil.
const charts = {
chapters: { value: w => w.chapters, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${w.chapters} caps.` },
pages: { value: w => w.pages, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${fmtNum(w.pages)} págs.` },
}
const drawCharts = () => app.querySelectorAll('.chart-slot').forEach(el => { el.innerHTML = barChart(st.weeks, charts[el.dataset.chart], Math.max(260, el.clientWidth)) })
drawCharts()
let rt = null
const onResize = () => { clearTimeout(rt); rt = setTimeout(drawCharts, 150) }
window.addEventListener('resize', onResize)
// Tooltip compartido para barras, celdas y géneros.
const tipEl = $('#tip')
const move = e => {
const target = e.target.closest('[data-tip]')
if (!target) { tipEl.hidden = true; return }
tipEl.textContent = target.dataset.tip
tipEl.hidden = false
const r = tipEl.getBoundingClientRect()
tipEl.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, e.clientX - r.width / 2))}px`
tipEl.style.top = `${Math.max(8, e.clientY - r.height - 12)}px`
}
app.addEventListener('pointermove', move)
app.addEventListener('pointerdown', move)
app.addEventListener('pointerleave', () => (tipEl.hidden = true))
cleanup = () => { app.removeEventListener('pointermove', move); app.removeEventListener('pointerdown', move); window.removeEventListener('resize', onResize) }
}
// ---------- 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">
<section><b>Cuenta</b><div class="muted">Sesión iniciada como <b>${esc(me.username)}</b></div>
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
<section><b>Idioma preferido de los capítulos</b>
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
<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>
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="8">
<button class="btn primary">${icon('key')} Guardar</button></form></section>
<section><b>${icon('download')} Descarga anticipada</b>
<p class="muted" style="margin:0;font-size:.88rem">Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).</p>
<div class="row" style="flex-wrap:wrap;gap:10px">
<select id="pfn">${[[0, 'Desactivada'], [1, 'El siguiente capítulo'], [2, '2 capítulos siguientes'], [3, '3 capítulos siguientes'], [5, '5 capítulos siguientes']].map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
</div></section>
<section><b>${icon('phone')} App y lectura sin conexión</b>
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.</p>
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
<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 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).
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://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>
<input type="password" name="tgToken" placeholder="Token del bot (de @BotFather)" autocomplete="new-password">
<input type="text" name="tgChat" placeholder="Chat ID" autocomplete="off">
<p class="muted" style="margin:0;font-size:.8rem">Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.</p>
<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
f.ntfyUrl.value = n.ntfyUrl
f.ntfyTopic.value = n.ntfyTopic
f.tgChat.value = n.tgChat
if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
}).catch(() => {})
$('#notify').addEventListener('submit', async e => {
e.preventDefault()
const f = e.target
try {
await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) })
toast('Avisos guardados')
viewSettings()
} catch (err) { toast(err.message) }
})
const renderBans = async () => {
const b = await api('/api/bans').catch(() => null)
const box = $('#bans')
if (!box || !b) return
box.innerHTML = `<p style="margin:0">Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.</p>
${b.bans.length ? `<div class="banlist">${b.bans.map(x => `<div class="row"><code>${esc(x.ip)}</code><span class="muted small" style="flex:1">hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')}</span>
<button class="btn small" data-unban="${esc(x.ip)}">Desbloquear</button></div>`).join('')}</div>` : '<p style="margin:6px 0 0">Ninguna IP bloqueada.</p>'}
<p class="muted small" style="margin:6px 0 0">Tu IP vista por el servidor: <code>${esc(b.you.ip)}</code>${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de <code>${esc(b.trustedHeader)}</code>` : ''}</p>
${b.fails.length ? `<p style="margin:6px 0 0">Intentos fallidos recientes: ${b.fails.map(f => `<code>${esc(f.ip)}</code> ×${f.n}`).join(', ')}</p>` : ''}`
box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => {
try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) }
}))
}
renderBans()
api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {})
const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message))
$('#pfn').addEventListener('change', savePf)
$('#pfhd').addEventListener('change', savePf)
if (OFFLINE_OK) {
const dev = offByManga()
$('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno'
$('#offclear').hidden = !dev.length
$('#offclear').addEventListener('click', async () => {
if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return
await caches.delete(OFF_CACHE)
store.set('offlineChapters', {})
toast('Borrado')
viewSettings()
})
const inst = $('#install')
inst.hidden = !installPrompt
inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true })
}
$('#ntest').addEventListener('click', async () => {
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
})
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
$('#contentmode').addEventListener('change', async e => {
const prev = contentMode()
try {
await api('/api/settings', { method: 'POST', body: { contentMode: e.target.value } })
me.content_mode = e.target.value
toast('Guardado')
} catch (err) { e.target.value = prev; toast(err.message) }
})
$('#pw').addEventListener('submit', async e => {
e.preventDefault()
try {
await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } })
e.target.reset(); toast('Contraseña cambiada')
} catch (err) { toast(err.message) }
})
}