'}`
}
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 = `
${icon('sparkles')} Recomendado para ti
Según lo que más lees: ${r.genres.map(g => `${esc(g.name)}`).join(', ')}
${r.items.map(mangaCard).join('')}
`
$('#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 = `
${icon('download')} Descargado en el servidor
Cargando…
`
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
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 = `
Continuar leyendo
${cont.slice(0, 12).map(continueCard).join('')}
`
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 = `
${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.'}
`)
}
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 source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
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 = `
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.
`
}
async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '
No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a buscarlo en LeerCapitulo o M440.
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}
`}
`
const secHead = (ic, title, status) => `
${icon(ic)} ${title}
${status ? `
${status}
` : ''}
`
// --- 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 = `${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}
`
$('#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()
$('#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) => ``
const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
box.innerHTML = `
Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.
' : ''}
${sel.mode === 'pending' ? `
Todos los capítulos descargados que aún no están en HD.
` : ''}
`
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)
const some = d?.scope === 'some'
const partial = d && d.done < d.total
const bar = d && (active || partial) ? `
` : ''
const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
const pickBtn = label => (canPick ? `` : '')
const delBtn = d ? `` : ''
let status = '', body = '', actions = ''
if (!d) {
body = '
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.
'
actions = `${pickBtn('Elegir capítulos…')}`
} else if (active) {
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
body = `${bar}
${downloadDetail(d)}
`
actions = `${pickBtn('Añadir capítulos…')}${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 ? `
${esc(d.error)}
` : '')
+ (some && d.status === 'done' ? '
Solo capítulos elegidos: los nuevos no se descargan solos.
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).
`
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á.`)
}
const setPageLabel = () => {
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
slider.value = lastShown() + 1
}
let observer = null
let atEnd = false
// --- 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
function groupOf(i) {
if (!isDouble()) return [i]
for (let s = 0; s < total;) {
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
if (g.includes(i)) return g
s += g.length
}
return [i]
}
// Las 4 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso).
function preload() {
const load = i => {
const im = new Image()
im.onload = () => isWide(im) && wide.add(i)
im.src = pages[i]
}
for (let k = 1; k <= 4 && shown.at(-1) + k < total; k++) load(shown.at(-1) + k)
for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) load(shown[0] - k)
}
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
function showPaged(dir = 0) {
finishTurn()
const old = $('.stage', box)
const stage = document.createElement('div')
stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => ``).join('')
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir))
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) && !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()
}
// --- 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
// 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.
function paperCurl(r, frontSrc, backSrc, spineLeft) {
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.
const OV = 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) => `
${src ? `` : ''}
`
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
// Á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 strips.map((t, 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
})
}
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
function set(g, reverse) {
let prev = 0, lift = 0
angles(g, reverse).forEach((a, i) => {
const t = strips[i]
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)`
prev = a
const c = Math.cos((a * Math.PI) / 180)
// 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
}
// 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 { el, set, span, solve }
}
// 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')
let kind = 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: '' }) }
const easeOut = t => 1 - (1 - t) ** 3
// step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance.
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 {
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
// Medidas relativas al contenedor.
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 byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
let rect, frontSrc, backSrc = 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)
frontSrc = front.src
backSrc = (sign > 0 ? newX[0] : newX.at(-1)).src
spineLeft = sign > 0
const keep = document.createElement('img')
keep.className = 'leafkeep'
keep.src = stay.src
place(keep, rel(stay))
box.append(keep)
extra.push(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)
frontSrc = img.src
spineLeft = settings.dir !== 'rtl'
reverse = dir < 0
if (dir > 0) old.style.visibility = 'hidden'
}
// Sombra que la hoja levantada proyecta sobre lo que hay debajo.
const drop = document.createElement('i')
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
place(drop, rect)
const curl = paperCurl(rect, frontSrc, backSrc, spineLeft)
place(curl.el, rect)
box.append(drop, curl.el)
extra.push(drop, curl.el)
;[g0, g1] = double ? [0, 1] : curl.span(reverse)
step = g => { drop.style.opacity = (curl.set(g, reverse) * 0.8).toFixed(3) }
dur = double ? 700 : 560
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,
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() {
finishTurn()
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
}
function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) {
if (n >= total) return showEnd()
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 ? 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); return }
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
function render() {
observer?.disconnect()
const keep = lastShown()
resetZoom()
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
renderModeQuick()
box.dataset.dir = settings.dir
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
slider.dir = isPaged() && settings.dir === 'rtl' ? 'rtl' : 'ltr'
$('#dir').hidden = !isPaged()
if (isPaged()) {
box.innerHTML = ''
atEnd = false
shown = []
goTo(keep)
return
}
page = keep
shown = [keep]
box.innerHTML = pages.map((src, i) => ``).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.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
}), { 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 = 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)
$('#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 && (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 }
;(xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward()
}
box.addEventListener('pointerdown', e => {
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página
if (e.pointerType === 'mouse' && e.button !== 0) return
if (settings.sfx !== 'off') sfxUnlock()
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pts.size === 1) {
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
if (gesture) box.setPointerCapture?.(e.pointerId)
} 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 ? shown.at(-1) + 1 < total : page > 0) {
const prev = [...shown]
finishTurn()
wantDrag = true
try { fwd ? forward() : backward() } finally { wantDrag = false }
if (turning?.move) {
gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
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) turnSound(settings.sfx, turnPan(g.dir))
g.ctl.release(commit, ok => {
if (ok) return
shown = g.prev
page = g.prev[0]
setPageLabel()
scheduleSave()
})
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 && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
}
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', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
const savePrefs = () => {
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
}
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() })
$('#modequick').addEventListener('click', () => {
settings.mode = isPaged() ? 'vertical' : lastPaged
savePrefs()
render()
})
$('#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; slider.dir = settings.dir; 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, lastShown())); router() })
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); 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 }
})
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
$('#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; 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 para
// poder desplazarse (rueda) en vertical mientras se recorta.
let crop = null
function endCrop() {
if (!crop) return
crop.ov.remove()
window.removeEventListener('scroll', crop.draw)
crop = null
$('#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.remove('hide-ui')
$('.rpanel', reader).hidden = true
const ov = document.createElement('div')
ov.className = 'cropov'
ov.innerHTML = `
${icon('scissors')} Arrastra sobre el panel que quieres guardar
`
reader.append(ov)
$('#clipbtn').classList.add('on')
const rectEl = $('.crop-rect', ov)
crop = { ov, r: null, start: null }
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
}
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)) return
ov.setPointerCapture(e.pointerId)
crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY }
crop.r = null
rectEl.hidden = true
$('[data-a=save]', ov).disabled = true
})
ov.addEventListener('pointermove', e => {
if (!crop.start) return
const x = e.clientX + scrollX, y = e.clientY + scrollY
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()
})
const up = () => {
if (!crop?.start) return
crop.start = null
const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12
if (!ok) { crop.r = null; rectEl.hidden = true }
$('[data-a=save]', ov).disabled = !ok
}
ov.addEventListener('pointerup', up)
ov.addEventListener('pointercancel', up)
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
$('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button')))
}
// La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen.
function cropTarget(r) {
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
let best = null
for (const img of box.querySelectorAll('img')) {
if (!img.complete || !img.naturalWidth) continue
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 && (!best || w * h > best.area)) best = { img, b, area: w * h }
}
if (!best) return null
const { img, b } = best
const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top
const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top
return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height }
}
async function saveCrop(btn) {
const t = crop?.r && cropTarget(crop.r)
if (!t) return toast('Marca una zona sobre la página')
btn.disabled = true
try {
const u = new URL(t.img.currentSrc || t.img.src, location.href)
u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo
const src = await new Promise((resolve, reject) => {
const im = new Image()
im.onload = () => resolve(im)
im.onerror = () => reject(new Error('No se pudo cargar la página'))
im.src = u.href
})
const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight)
const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight))
if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña')
const k = Math.min(1, 4000 / Math.max(sw, sh))
const cv = document.createElement('canvas')
cv.width = Math.round(sw * k); cv.height = Math.round(sh * k)
cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height)
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), 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 (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 (!isPaged() || atEnd) return
box.classList.toggle('double', isDouble())
goTo(lastShown(), false, true)
}, 200)
}
window.addEventListener('resize', onResize)
render()
cleanup = () => {
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' ? `HD`
: c.status === 'error' ? 'HD!'
: c.status === 'off' ? '' : 'HD…'
async function viewClips(params) {
const mangaId = params.get('manga')
const here = () => location.hash.split('?')[0] === '#/clips'
app.innerHTML = '
Cargando…
'
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 = `
` : '')
} 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 ? `
${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.
` : '')
+ 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)} ${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.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 = '
Esa lista ya no existe.
'
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 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 = '
Cargando…
'
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 ? `${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose` : '',
q.clips.errors ? `${plural(q.clips.errors, 'recorte')} con error` : '',
q.covers ? `${icon('image')} ${plural(q.covers, 'portada')} en cola` : '',
].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 = '' }) => `
`
// 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 `
${icon('phone')} En este dispositivo ${items.length ? `${items.length}` : ''}
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: ${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}.` : ''}
${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no has descargado nada. Usa “Descargar” en la ficha de un manga.'}
`}
`
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
let wrap = $('#qwrap')
if (!wrap) {
app.innerHTML = `
${html}
`
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) {
if (!confirm(`¿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' }), '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 => `${fmtNum(Math.round(v))}`).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 ? `` : ''
const every = W < 420 ? 3 : 2
const lab = (data.length - 1 - i) % every ? '' : `${label(d)}`
return `${bar}${lab}`
}).join('')
return ``
}
async function viewStats() {
app.innerHTML = '
Cargando…
'
const st = await api('/api/stats')
const t = st.totals
if (!t.pages) {
app.innerHTML = '
Estadísticas
Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.
Instalar la app y leer sin conexión necesita que la página se abra por HTTPS (el navegador no lo permite en http://${esc(location.host)}). Por ejemplo con tailscale serve o un proxy inverso con certificado.
`}
${icon('key')} Seguridad: IPs bloqueadas
Cargando…
${icon('bell')} Avisos de capítulos nuevos
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:
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.
Tu IP vista por el servidor: ${esc(b.you.ip)}${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de ${esc(b.trustedHeader)}` : ''}