No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
Prueba a buscarlo en LeerCapitulo.
${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}
` : ''}
`
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`
// --- 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`)
},
},
}
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')
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]
}
function preload() {
for (let k = 1; k <= 4; k++) {
const i = shown.at(-1) + k
if (i >= total) break
const im = new Image()
im.onload = () => isWide(im) && wide.add(i)
im.src = pages[i]
}
}
function showPaged() {
let stage = $('.stage', box)
if (!stage) { box.innerHTML = ''; stage = $('.stage', box) }
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => ``).join('')
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()
}
function showEnd() {
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
}
function goTo(n, fromScroll = false, force = false) {
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
atEnd = false
shown = g
page = g[0]
setPageLabel()
if (changed || force || !$('.stage img', box)) showPaged()
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) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
if (page > 0) goTo(groupOf(page - 1)[0])
}
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', 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 (e.pointerType === 'mouse' && e.button !== 0) return
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 (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true
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 (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)
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 })
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() })
$('#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) })
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 = `
`
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async e => {
e.preventDefault()
e.stopPropagation()
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
viewHistory()
}))
}
// ---------- Descargas ----------
async function viewDownloads() {
if (cleanup) { cleanup(); cleanup = null }
const list = await api('/api/downloads')
if (location.hash !== '#/downloads') return
const total = list.reduce((n, d) => n + (d.size || 0), 0)
app.innerHTML = `
Descargas
Se guardan en /zdata/media/mangas como CBZ (compatibles con Kavita, Komga, Mihon…). Los capítulos descargados se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)}.` : ''}
${list.length ? '' : '
Aún no has descargado nada. Usa “⬇ Descargar” en la ficha de un manga.
'}
${list.map(d => {
const active = DL_ACTIVE.includes(d.status)
return `
`
// 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 ----------
function viewSettings() {
app.innerHTML = `
Ajustes
Cuenta
Sesión iniciada como ${esc(me.username)}
Idioma preferido de los capítulosCambiar contraseña${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: