Libros de texto: fuente Project Gutenberg y lectura continua

Los EPUB se convierten en el servidor en una lista cerrada de bloques y el
navegador los maqueta en páginas según su pantalla. Cada página se dibuja en
un canvas y llega al lector como una imagen más, así que hoja, sombra,
hendidura y sonido no cambian.

El libro se lee de corrido: cada capítulo empieza debajo del anterior y el
progreso se sigue guardando por capítulo. El catálogo sale del OPDS de
gutenberg.org, con reintentos.

Ajustes: mostrar solo manga, solo libros o ambos, por usuario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:02:29 -06:00
co-authored by Claude Opus 5.5
parent 5776389ebb
commit be19e35156
6 changed files with 1089 additions and 66 deletions
+596 -49
View File
@@ -50,8 +50,14 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
// 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)' }
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)' }
const NO_CATS = new Set(['mi', 'gb']) // fuentes sin listado por categorías
const BOOK_SOURCES = new Set(['gb'])
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', ''],
@@ -431,6 +437,7 @@ function continueCard(h) {
}
// 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 ''
@@ -484,7 +491,8 @@ async function viewHomeOffline(params) {
<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 [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
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>`
@@ -517,7 +525,9 @@ async function viewHome(params) {
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')
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)
@@ -525,12 +535,12 @@ async function viewHome(params) {
app.innerHTML = `
<section id="cont"></section>
<section id="recs"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
<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>
${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')} Actualizados recientemente</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>
@@ -548,10 +558,10 @@ async function viewHome(params) {
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), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
$('#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(() => {})
loadRecs()
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
}
const res = $('#results')
@@ -564,7 +574,7 @@ async function viewHome(params) {
}
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q)
const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q)
const seen = new Set()
let page = 1
const load = async () => {
@@ -650,6 +660,7 @@ async function viewManga(id, langOverride) {
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)}`
@@ -663,8 +674,8 @@ async function viewManga(id, langOverride) {
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
const menuItems = [
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
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>`,
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('')
@@ -697,7 +708,7 @@ async function viewManga(id, langOverride) {
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
${menuItems}
</div>
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
<div class="libpanel" ${isBook ? 'style="display:none"' : ''}><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></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">
@@ -735,7 +746,7 @@ async function viewManga(id, langOverride) {
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
}
paintInfo(savedInfo)
if (!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 = '' })
}
@@ -1116,7 +1127,7 @@ async function viewManga(id, langOverride) {
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
b.hidden = offline || isBook
}
renderFollow()
$('#follow').addEventListener('click', async () => {
@@ -1224,7 +1235,7 @@ async function viewManga(id, langOverride) {
updateSummary()
})
renderList()
api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); 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()
@@ -1330,12 +1341,418 @@ function turnSound(level, pan = 0) {
}
// 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
}
async function viewReader(mangaId, chapterId, startPage) {
document.body.classList.add('reading')
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
@@ -1353,17 +1770,54 @@ async function viewReader(mangaId, chapterId, startPage) {
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
const prev = idx > 0 ? chapters[idx - 1] : null
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
const pages = chap.pages
const total = pages.length
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
let shown = [page] // páginas visibles (2 en doble página)
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
}
const 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
}
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 payload = () => ({
const bookPayload = (c, local, count) => ({
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
})
const payload = () => {
if (!book) return mangaPayload()
const p = lastShown(), c = book.chapterAt(p)
return bookPayload(c, p - book.chapterStart(c), book.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"
@@ -1376,21 +1830,22 @@ async function viewReader(mangaId, chapterId, startPage) {
window.addEventListener('pagehide', beacon)
saveNow()
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
let curChap = book ? book.chapterAt(page) : idx
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="#/manga/${mangaId}" title="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" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
<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 manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
<select id="fit" 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 manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<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>
@@ -1398,16 +1853,20 @@ async function viewReader(mangaId, chapterId, startPage) {
${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>` : ''}
${OFFLINE_OK ? `<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">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>
${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="pages"></div>
<div class="rbar bottom">
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
${book ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
<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>
${next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
${book ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
</div>
</div>`
const reader = $('.reader', app)
@@ -1434,7 +1893,8 @@ async function viewReader(mangaId, chapterId, startPage) {
applyFilter()
if ($('#saver')) $('#saver').checked = settings.saver
if ($('#hd')) $('#hd').checked = settings.hd !== false
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
@@ -1443,12 +1903,53 @@ async function viewReader(mangaId, chapterId, startPage) {
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()
book.trim(p)
if (!isPaged()) {
box.querySelectorAll('img[src]').forEach(im => {
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
})
}
const c = book.chapterAt(p)
if (c !== curChap) {
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
for (let j = curChap; j < c; j++) {
const count = book.chapterPages(j)
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
}
}
curChap = c
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
}
lastBookPage = p
syncBookNav()
}
const setPageLabel = () => {
if (book) 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()
@@ -1479,9 +1980,10 @@ async function viewReader(mangaId, chapterId, startPage) {
wide.add(i)
refreshGutters() // una página ancha cambia las parejas de las páginas de delante
}
im.src = pages[i]
im.decode?.().catch(() => {})
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()
@@ -1517,7 +2019,7 @@ async function viewReader(mangaId, chapterId, startPage) {
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="${pages[i]}" alt="" draggable="false">`).join('')
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)) {
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
@@ -2100,7 +2602,7 @@ async function viewReader(mangaId, chapterId, startPage) {
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: pages[i] })
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'
@@ -2286,6 +2788,7 @@ async function viewReader(mangaId, chapterId, startPage) {
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()
@@ -2302,10 +2805,16 @@ async function viewReader(mangaId, chapterId, startPage) {
}
page = keep
shown = [keep]
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
// 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.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
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
@@ -2317,7 +2826,7 @@ async function viewReader(mangaId, chapterId, startPage) {
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 }) }))
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
@@ -2493,14 +3002,15 @@ async function viewReader(mangaId, chapterId, startPage) {
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))
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))
}
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() })
// 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
savePrefs()
render()
savePrefsThenRender()
})
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
$('#dir').addEventListener('change', e => {
@@ -2523,7 +3033,21 @@ async function viewReader(mangaId, chapterId, startPage) {
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) })
$('#chap').addEventListener('change', e => {
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
else location.hash = readUrl(mangaId, e.target.value)
})
if (book) {
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
$('#bprev').addEventListener('click', e => {
e.preventDefault()
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
let c = curChap
while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
goTo(book.chapterStart(c))
})
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
}
$('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button')
b.disabled = true
@@ -2742,6 +3266,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const onResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
if (book && sizeChanged()) return reopenBook()
if (!isPaged() || atEnd) return
box.classList.toggle('double', isDouble())
goTo(lastShown(), false, true)
@@ -2749,8 +3274,19 @@ async function viewReader(mangaId, chapterId, startPage) {
}
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()
})
}
}
render()
cleanup = () => {
book?.release()
freeLeafGL()
endCrop()
if (saveTimer) saveNow()
@@ -2900,16 +3436,16 @@ async function viewLibrary(params) {
: [null, null, []]
if (!location.hash.startsWith('#/library')) return
const online = !!hist
const dev = OFFLINE_OK ? offByManga() : []
const reading = (hist || []).filter(h => !h.finished)
const finished = (hist || []).filter(h => h.finished)
const follows = fol?.follows || []
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', l.count]),
...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 = `<h1>Librería</h1>
@@ -2952,7 +3488,7 @@ async function viewLibrary(params) {
}), '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({
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».')
@@ -3292,6 +3828,9 @@ function viewSettings() {
<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>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>
@@ -3382,6 +3921,14 @@ function viewSettings() {
})
$('#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 {