const app = document.getElementById('app') // Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto. const ICONS = { home: '', history: '', download: '', settings: '', play: '', book: '', stopRead: '', stop: '', trash: '', refresh: '', search: '', sort: '', back: '', prev: '', next: '', more: '', check: '', login: '', logout: '', userPlus: '', flame: '', clock: '', grid: '', wifiOff: '', globe: '', key: '', bell: '', bellOff: '', chart: '', zoomOut: '', send: '', dots: '', x: '', scroll: '', pages: '', scissors: '', image: '', queue: '', library: '', bookmark: '', phone: '', plus: '', pencil: '', sparkles: '', } const icon = name => `` // Iconos del menú superior (index.html). document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}${a.textContent}` }) 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 // Filtros de imagen del lector (CSS filter); el brillo se suma aparte. const FILTERS = { none: ['Sin filtro', ''], sepia: ['Sepia', 'sepia(.65) contrast(.95)'], paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'], vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'], night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'], gray: ['Blanco y negro', 'grayscale(1)'], contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'], vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'], // Sin filtro CSS: una capa ámbar en modo multiplicar sobre todo el lector (.reader.bluelight), que // quita azul a todo lo que hay en pantalla, también durante las animaciones. blue: ['Filtro de luz azul', ''], } const DL_ACTIVE = ['queued', 'downloading'] const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}` const fmtDur = sec => sec < 90 ? '≈ 1 min' : sec < 3600 ? `≈ ${Math.round(sec / 60)} min` : `≈ ${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min` const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null } const fmtDate = d => { const t = new Date(`${String(d).slice(0, 10)}T12:00:00`) return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('es', { day: 'numeric', month: 'short', year: 'numeric' }) } const fmtSize = b => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${Math.round(b / 1e6)} MB` : `${Math.round((b || 0) / 1e3)} KB`) // Progreso global contando la fracción del capítulo en curso. function downloadPct(d) { if (!d?.total) return 0 const frac = d.current?.pagesTotal ? d.current.pages / d.current.pagesTotal : 0 return Math.min(100, ((d.done + frac) / d.total) * 100) } function downloadDetail(d) { const c = d?.current if (!c) return d?.status === 'queued' ? 'Esperando turno…' : '' return c.pagesTotal ? `Cap. ${esc(c.chapter ?? '?')} · pág. ${c.pages}/${c.pagesTotal}` : `Cap. ${esc(c.chapter ?? '?')} · preparando…` } function downloadLabel(d) { if (!d) return '' const n = `${d.done}/${d.total || '?'}` return { queued: 'En cola…', downloading: `Descargando ${n}`, done: `Descargado (${d.done} caps.)`, error: `Descarga incompleta ${n}`, cancelled: `Descarga cancelada ${n}`, }[d.status] || d.status } const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' } let me = null let cleanup = null // ---------- Utilidades ---------- const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) const $ = (sel, root = document) => root.querySelector(sel) // Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML). document.addEventListener('error', e => { const img = e.target if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return img.dataset.failed = '1' img.src = '/icon.svg' }, true) const store = { get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} }, } function toast(msg) { const t = document.getElementById('toast') t.textContent = msg t.hidden = false clearTimeout(toast.t) toast.t = setTimeout(() => (t.hidden = true), 2500) } const NO_SERVER = 'Sin conexión con el servidor' async function api(path, opts = {}) { // Si tarda, se comprueba si el servidor sigue ahí (una fuente lenta no es motivo para cortar, un // servidor que no contesta sí): así nada se queda en «Cargando…» para siempre sin red. const ctrl = new AbortController() const watchdog = setTimeout(async () => { if (!(await pingServer())) ctrl.abort() }, 2500) let res try { res = await fetch(path, { ...opts, signal: ctrl.signal, headers: opts.body ? { 'Content-Type': 'application/json' } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined, }) } catch { setServerReachable(false) throw new Error(NO_SERVER) } finally { clearTimeout(watchdog) } // Respuesta de la copia del service worker: el servidor no contestó a tiempo; comprobar el estado. if (res.headers.get('x-from-cache')) refreshNet() else if (!serverReachable) setServerReachable(true) const data = await res.json().catch(() => ({})) if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') } // 503: el servidor ya sabe que no hay internet. 502: una fuente falló; el servidor tarda unos segundos en confirmarlo. if (res.status === 503) refreshNet() else if (res.status === 502) setTimeout(refreshNet, 7000) if (!res.ok) throw new Error(data.error || `Error ${res.status}`) return data } // ---------- Estado de la conexión ---------- // Dos casos distintos, con su aviso arriba: // - serverReachable = false: no se llega al servidor (sin red, servidor caído). Se lee lo guardado en // este dispositivo; la portada lo muestra. Al volver, se envía el progreso pendiente. // - serverOnline = false: el servidor contesta pero él no tiene internet. Se lee lo descargado en el // servidor; la portada lo muestra. // Se comprueba cada 20 s (cada 10 s sin servidor) y al volver a la pestaña. let serverOnline = true let serverReachable = true let netChecked = false const isHome = () => !(location.hash.slice(1).split('?')[0] || '/').split('/').filter(Boolean).length async function pingServer() { const r = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) }).catch(() => null) return r ? (r.ok ? await r.json().catch(() => ({ online: true })) : { online: true }) : null } function netBanner() { let b = $('#netbanner') const msg = !serverReachable ? `${icon('wifiOff')}${NO_SERVER}. ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.` : !serverOnline ? `${icon('wifiOff')}El servidor no tiene internet. Puedes leer lo descargado en el servidor; se reconecta solo.` : '' if (!msg) return b?.remove() if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) } if (b.innerHTML !== msg) b.innerHTML = msg } function setServerReachable(ok) { if (ok === serverReachable) return serverReachable = ok netBanner() if (ok) { flushProgress(); refreshNet() } if (isHome()) router() } async function refreshNet() { const st = await pingServer() netChecked = true if (!st) return setServerReachable(false) const changed = !serverReachable || st.online !== serverOnline serverOnline = st.online !== false if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar netBanner() if (changed && isHome()) router() } setInterval(() => { if (serverReachable) refreshNet() }, 20e3) setInterval(() => { if (!serverReachable) refreshNet() }, 10e3) document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() }) function timeAgo(ts) { const s = (Date.now() - ts) / 1000 if (s < 60) return 'hace un momento' if (s < 3600) return `hace ${Math.floor(s / 60)} min` if (s < 86400) return `hace ${Math.floor(s / 3600)} h` return `hace ${Math.floor(s / 86400)} d` } const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '') const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}` // ---------- Sin conexión (capítulos guardados en este dispositivo) ---------- // Requiere service worker, que el navegador solo permite con HTTPS (o en localhost). const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window const OFF_CACHE = 'offline-v1' // Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL. if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {}) const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at } const offHas = id => !!offIdx()[id] const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null } const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} }) // Capítulos guardados agrupados por manga. function offByManga() { const g = new Map() for (const [chapterId, c] of Object.entries(offIdx())) { const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 } m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at) g.set(c.mangaId, m) } return [...g.values()].sort((a, b) => b.at - a.at) } // items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }] function offEnqueue(items) { for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it) navigator.storage?.persist?.().catch(() => {}) offEmit() offRun() } async function offRun() { if (offState.running) return while (offState.queue.length) { const it = offState.queue.shift() offState.running = { ...it, done: 0, total: 0 } offEmit() try { await offSaveChapter(it) } catch (err) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) } offState.cancelled.delete(it.chapterId) } offState.running = null offEmit() } // Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en // curso se interrumpe y se borra lo que ya se había guardado de él. function offCancel(mangaId = null) { offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId) if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) { offState.cancelled.add(offState.running.chapterId) offState.abort?.abort() // corta las páginas que se estén bajando } offEmit() } async function offSaveChapter(it) { const cache = await caches.open(OFF_CACHE) const saved = [] offState.abort = new AbortController() try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) { await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias throw err } } async function offSaveInto(cache, it, saved, signal) { const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') } const hd = store.get('readerSettings', {}).hd !== false const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}` const res = await fetch(chapUrl, { signal }) if (!res.ok) throw new Error(`Error ${res.status}`) const data = await res.clone().json() await cache.put(chapUrl, res) saved.push(chapUrl) const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}` const mres = await fetch(mUrl).catch(() => null) if (mres?.ok) await cache.put(mUrl, mres) if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) } offState.running.total = data.pages.length let bytes = 0, i = 0 const worker = async () => { while (i < data.pages.length) { stop() const u = data.pages[i++] // Hasta 3 intentos por página (las fuentes fallan a ratos), cada uno con tiempo límite. let blob, type for (let t = 1; ; t++) { try { const r = await fetch(u, { signal: AbortSignal.any([signal, AbortSignal.timeout(90e3)]) }) if (!r.ok) throw new Error(`página ${r.status}`) type = r.headers.get('Content-Type') blob = await r.blob() break } catch (err) { stop() if (t >= 3) throw err await new Promise(res => setTimeout(res, 2000 * t)) } } bytes += blob.size stop() await cache.put(u, new Response(blob, { headers: { 'Content-Type': type || 'image/jpeg' } })) saved.push(u) offState.running.done++ offEmit() } } await Promise.all([worker(), worker(), worker()]) stop() const idx = offIdx() idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() } store.set('offlineChapters', idx) } async function offRemove(chapterIds) { const cache = await caches.open(OFF_CACHE) const idx = offIdx() for (const id of chapterIds) { const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true }) if (hit) { const data = await hit.json().catch(() => ({ pages: [] })) await Promise.all((data.pages || []).map(u => cache.delete(u))) await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true }) } const mangaId = idx[id]?.mangaId delete idx[id] if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true }) } store.set('offlineChapters', idx) offEmit() } // Progreso leído sin conexión: se guarda aquí y se envía al volver la red. function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) } async function flushProgress() { const q = store.get('pendingProgress', {}) for (const [k, p] of Object.entries(q)) { try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo } store.set('pendingProgress', q) } window.addEventListener('online', flushProgress) setTimeout(flushProgress, 3000) // Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes). let installPrompt = null window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e }) // ---------- Router ---------- let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector async function router() { if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash } if (cleanup) { cleanup(); cleanup = null } document.body.classList.remove('reading') const [pathPart, query] = location.hash.slice(1).split('?') const parts = (pathPart || '/').split('/').filter(Boolean) const params = new URLSearchParams(query || '') document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`)) if (!me) { const [r] = await Promise.all([api('/api/me').catch(() => ({})), netChecked ? null : refreshNet()]) me = r.user me && (me.signup = r.signup) if (!me) return viewAuth(r.signup) } try { if (parts[0] === 'login') return (location.hash = '#/') if (parts[0] === 'manga') return await viewManga(parts[1]) if (parts[0] === 'info') return (location.hash = `#/manga/${parts[1]}`) // la ficha ya incluye la info if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre if (parts[0] === 'library') return await viewLibrary(params) if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre if (parts[0] === 'queues') return await viewQueues() if (parts[0] === 'stats') return await viewStats() if (parts[0] === 'clips') return await viewClips(params) if (parts[0] === 'settings') return viewSettings() return await viewHome(params) } catch (e) { if (e.message !== 'No autenticado') app.innerHTML = `

${esc(e.message)}

` } } window.addEventListener('hashchange', router) router() // ---------- Login / registro ---------- function viewAuth(signup) { app.innerHTML = `

Manga Reader

Inicia sesión para leer y guardar tu progreso.

${signup ? `` : ''}
` const form = $('form', app) form.addEventListener('submit', async ev => { ev.preventDefault() const mode = ev.submitter?.value === 'register' ? 'register' : 'login' const fd = new FormData(form) try { await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } }) me = null location.hash = '#/' router() } catch (e) { $('.error', form).textContent = e.message } }) } // ---------- Inicio ---------- function mangaCard(m) { return `
${esc(m.title)}
${m.sub ? `
${esc(m.sub)}
` : ''}
` } function continueCard(h) { const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0 // Terminado con capítulos nuevos: a la ficha, para elegir el siguiente. return `
${esc(h.manga_title)}
${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}
${h.finished ? '
Terminado
' : h.hasNew ? '
Caps. nuevos
' : ''}
${timeAgo(h.updated_at)}
` } // Sección "Novedades": mangas seguidos con capítulos nuevos. function newsSection(fol) { const fresh = fol.follows.filter(f => f.newCount > 0) const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h` if (!fol.follows.length) return '' return `

${icon('sparkles')} Novedades ${fresh.length ? `${fresh.reduce((n, f) => n + f.newCount, 0)}` : ''}

Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
${fresh.length ? `` : '

No hay capítulos nuevos en los mangas que sigues.

'}` } 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 = `

${icon('phone')} En este dispositivo

` + (dev.length ? `
${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}
` : `

${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 = `
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
${q && !tag ? `

Resultados para “${esc(q)}”

` : `
${NO_CATS.has(source) ? '' : ``}
`} ${tag ? `

Categoría: ${esc(tagName)} ✕${q ? ` · “${esc(q)}”` : ''}

` : ''}
Cargando…
` const form = $('form', app) const go = (extra = {}) => { const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra }) location.hash = `#/?${p}` } form.addEventListener('submit', e => { e.preventDefault(); go() }) $('[name=any]').addEventListener('change', () => go()) app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` })) app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` })) if (!q && !tag && !showCats) { Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => { if (!$('#cont')) return $('#cont').innerHTML = `
${newsSection(fol)}
` + (hist = hist.filter(h => !h.finished), hist.length ? `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` : '') bindNews(fol) }).catch(() => {}) loadRecs() } const res = $('#results') if (showCats) { const cats = await api(`/api/categories?source=${source}`) const groups = cats.reduce((m, c) => m.set(c.group, [...(m.get(c.group) || []), c]), new Map()) res.className = 'cats' res.innerHTML = [...groups].map(([g, list]) => `

${esc(g)}

${list.map(t => `${esc(t.name)}`).join('')}
`).join('') return } // 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 seen = new Set() let page = 1 const load = async () => { const qs = new URLSearchParams({ q, mode, source, page, ...(tag ? { tag } : {}), ...(anyLang ? { anyLang: '1' } : {}) }) const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id)) if (page === 1) { res.className = list.length ? 'grid' : 'loading' res.innerHTML = list.length ? '' : 'Sin resultados.' } res.insertAdjacentHTML('beforeend', list.map(mangaCard).join('')) $('#more').hidden = !pageable || list.length < 20 } $('#more').addEventListener('click', async e => { e.target.disabled = true page++ try { await load() } catch (err) { toast(err.message) } e.target.disabled = false }) await load() } // ---------- Ficha del manga ---------- // ---------- Datos de la obra de AniList y MangaUpdates (parte de la ficha) ---------- const AL_FORMAT = { MANGA: 'Manga', NOVEL: 'Novela ligera', ONE_SHOT: 'Oneshot', Manga: 'Manga', Manhwa: 'Manhwa', Manhua: 'Manhua' } const AL_STATUS = { FINISHED: 'Terminado', RELEASING: 'En publicación', NOT_YET_RELEASED: 'Sin estrenar', CANCELLED: 'Cancelado', HIATUS: 'En pausa' } const AL_COUNTRY = { JP: 'Japón', KR: 'Corea del Sur', CN: 'China', TW: 'Taiwán' } const AL_ROLE = r => r.replace(/\bchs?\b/g, 'caps.').replace(/^Story & Art/i, 'Historia y dibujo').replace(/^Original Creator/i, 'Creador original').replace(/^Original Story/i, 'Historia original').replace(/^Story/i, 'Historia').replace(/^Art/i, 'Dibujo') const AL_REL = { SEQUEL: 'Secuela', PREQUEL: 'Precuela', SIDE_STORY: 'Historia paralela', SPIN_OFF: 'Spin-off', ALTERNATIVE: 'Versión alternativa', PARENT: 'Obra principal', ADAPTATION: 'Adaptación', SOURCE: 'Original', CHARACTER: 'Personajes', SUMMARY: 'Resumen', COMPILATION: 'Recopilación', CONTAINS: 'Contiene', OTHER: 'Relacionado' } const AL_ANIME = { TV: 'Serie TV', TV_SHORT: 'Serie corta', MOVIE: 'Película', OVA: 'OVA', ONA: 'ONA', SPECIAL: 'Especial', MUSIC: 'Vídeo musical' } const fmtFuzzy = d => (!d ? '' : d.length === 4 ? d : d.length === 7 ? new Date(`${d}-15T12:00:00`).toLocaleDateString('es', { month: 'long', year: 'numeric' }) : fmtDate(d)) // Lo que la ficha pinta a partir de la información guardada de la obra (d.found). function infoParts(d, manga) { const row = (k, v) => (v ? `
${k}
${v}
` : '') const t = d.titles const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map()) const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : '' const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ') const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`) const cardOf = r => `
${esc(r.title)}
${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}
` const seen = new Set([manga.title.toLowerCase()]) const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase())) const srcDesc = (manga.description || '').trim() return { banner: d.banner, sub: altTitles.join(' · '), scores: `
${d.score != null ? `
${d.score}%AniList
` : ''} ${d.muScore != null ? `
${Number(d.muScore).toFixed(1)}MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}
` : ''} ${d.popularity ? `
${fmtNum(d.popularity)}lectores en AniList
` : ''}
${ranks.length ? `
${ranks.map(r => `${esc(r)}`).join('')}
` : ''}`, kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))} ${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` (${esc(m.publisher)})` : '')).join(', '))} ${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))} ${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))} ${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))} ${row('Publicación', esc(dates || manga.year || ''))} ${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))} ${row('Extensión', esc(size))} ${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}` : ''].filter(Boolean).join('
'))}`, genres: d.genres.map(g => `${esc(g)}`).join(''), softTags: d.tags.map(g => `${esc(g)}`).join(''), synopsis: `${srcDesc ? `

Sinopsis

${esc(srcDesc)}

` : ''} ${d.description ? (srcDesc ? `
Sinopsis de AniList (en inglés)

${esc(d.description)}

` : `

Sinopsis (en inglés)

${esc(d.description)}

`) : ''}`, extra: `${t.other.length ? `

Otros títulos

${t.other.map(esc).join(', ')}

` : ''} ${d.related.length ? `

Obras relacionadas

${d.related.map(cardOf).join('')}
` : ''} ${d.recommendations.length ? `

Si te gusta, prueba

${d.recommendations.map(cardOf).join('')}
` : ''} ${d.links.length ? `

Enlaces

${d.links.map(l => `${esc(l.site)}`).join('')}
` : ''}

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 = '
Cargando…
' const lang = langOverride || store.get(`lang:${id}`, me.lang) const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const dlSet = new Set(downloaded) const newSet = new Set(newKeys.map(String)) let isFollowing = following const readMap = new Map(reads.map(r => [r.chapter_id, r])) const isLc = manga.source !== 'md' // sitios en español sin selector de idioma const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}` const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}` const first = chapters[0] let mainBtn = '' if (progress && chapters.some(c => c.id === progress.chapter_id)) { mainBtn = `${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}` } else if (progress) { mainBtn = `${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}` } if (first && !mainBtn) mainBtn = `${icon('book')} Empezar a leer` const menuItems = [ manga.source === 'lc' ? '' : `${icon('search')} Buscar en LeerCapitulo`, manga.source === 'mi' ? '' : `${icon('search')} Buscar en M440`, progress || reads.length ? `` : '', ].join('') const srcTags = manga.tags.map(t => (t.id ? `${esc(t.name)}` : `${esc(t.name ?? t)}`)).join('') const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}` app.innerHTML = `

${esc(manga.title)}

${srcSub}
${mainBtn} ${chapters.length ? `` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''}

Capítulos (${chapters.length})${newSet.size ? ` ${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}` : ''}

${isLc ? '' : ``} ${chapters.length > 1 ? `` : ''}
${chapters.length ? '
    ' : isLc ? '

    Este manga no tiene capítulos.

    ' : `

    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.'}

    `} ` // --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar. const synBox = $('#msyn') const paintInfo = d => { if (!synBox.isConnected) return // ya se cambió de página const parts = d?.found ? infoParts(d, manga) : null const banner = $('#mbanner') // El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada. const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner banner.hidden = !bannerUrl banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : '' if (!manga.cover && d?.cover) $('#mcover').src = d.cover $('#msub').innerHTML = parts ? esc(parts.sub) : srcSub $('#mscores').innerHTML = parts?.scores || '' const folder = download ? `
    Carpeta
    mangas/${esc(download.folder)}
    ` : '' $('#mkv').innerHTML = (parts?.kv || '') + folder $('#mdata').hidden = !parts && !folder $('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '') const srcDesc = (manga.description || '').trim() synBox.innerHTML = parts?.synopsis || (srcDesc ? `

    Sinopsis

    ${esc(srcDesc)}

    ` : '') $('#mextra').innerHTML = parts?.extra || (d ? `

    No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en AniList.

    ` : '') } paintInfo(savedInfo) if (!savedInfo) { $('#mextra').innerHTML = '

    Buscando información de la obra…

    ' api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' }) } // --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra. const openLib = open => { $('#libdrawer').classList.toggle('open', open) $('#libdrawer').inert = !open $('#libback').hidden = !open } // El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo). const libBusy = {} const markLib = (kind, busy) => { libBusy[kind] = busy $('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' ' : ''}` } const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' }) $('#libbtn').addEventListener('click', () => openLib(true)) $('#libclose').addEventListener('click', () => openLib(false)) $('#libback').addEventListener('click', () => openLib(false)) $('#tochap')?.addEventListener('click', toChapters) const onLibKey = e => { if (e.key === 'Escape') openLib(false) } document.addEventListener('keydown', onLibKey) const secHead = (ic, title, status) => `
    ${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() if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla $('#dlopen')?.classList.toggle('on', sel?.kind === 'dl') $('#hdopen')?.classList.toggle('on', sel?.kind === 'hd') $('#offopen')?.classList.toggle('on', sel?.kind === 'off') updateSummary() } function renderSelForm(kind) { const box = $(`#${kind}form`) if (!box) return const open = sel?.kind === kind box.hidden = !open if (!open) { box.innerHTML = ''; return } const K = KINDS[kind] const base = K.rangeBase(), cand = K.candidates() const opt = (c, on) => `` const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1) box.innerHTML = `
    ${Object.entries(K.modes).map(([m, label]) => ``).join('')}
    ${sel.mode === 'range' ? `
    ` : ''} ${sel.mode === 'pick' ? '
    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) markLib('dl', !!active) 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.
    ' : '') const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null actions = some ? `${resume ? `` : ''}${pickBtn('Descargar más…')}${delBtn}` : `${delBtn}` } if (!$('#dltop')) box.innerHTML = '
    ' $('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `
    ${actions}
    `) $('#dlgo')?.addEventListener('click', async () => { // Reanudar respeta el modo (todo o capítulos elegidos). renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d))) }) $('#dlall')?.addEventListener('click', async () => { const missing = chapters.filter(c => !dlSet.has(c.id)).length if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } })) toast(`Descargando en mangas/${manga.title}`) }) $('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' })) $('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' }))) $('#dldel')?.addEventListener('click', async () => { if (!confirm(d.shared ? `¿Quitar «${manga.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan para él y lo puedes seguir leyendo.` : `¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return const r = await api(`/api/download/${id}`, { method: 'DELETE' }) toast(r.kept ? 'Quitado de tu biblioteca' : 'Copia local eliminada') viewManga(id, langOverride) }) const wantedBefore = wantedSet.size wantedSet = new Set(d?.wanted || []) if (wantedSet.size !== wantedBefore) refreshSel() clearTimeout(dlTimer) if (active) dlTimer = setTimeout(async () => { const next = await api(`/api/download/${id}`).catch(() => d) if (!$('#dl')) return if (next && next.done !== d.done) { const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null) fresh?.downloaded.forEach(x => dlSet.add(x)) if (fresh?.download) next.size = fresh.download.size if (fresh?.upscale) renderHd(fresh.upscale) refreshSel() } renderDl(next) }, 2000) } // --- Reescalado HD de lo descargado. let hdTimer = null const renderHd = u => { const box = $('#hd') if (!box) return clearTimeout(hdTimer) hdInfo = u const show = !!(u?.available && u.downloaded && !offline) box.hidden = !show markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error'))) if (show) { const queued = Object.values(u.jobs).filter(s => s !== 'error').length const active = !!u.current || queued > 0 const c = u.current const status = `${u.hd} de ${u.downloaded} capítulos${u.size ? ` · ${fmtSize(u.size)}` : ''}` const remaining = Object.entries(u.jobs).filter(([, st]) => st !== 'error').map(([cid]) => cid) let body = '' if (active) { const pct = c?.total ? (c.pages / c.total) * 100 : 0 body = `
    ${c ? `Cap. ${esc(c.num ?? '?')} · pág. ${c.pages}/${c.total}` : 'Esperando turno…'}${queued > 1 ? ` · ${queued - 1} más en cola` : ''} · faltan ${fmtDur(remaining.reduce((n, cid) => n + (u.pages?.[cid] || 0), 0) * u.secPerPage - (c?.pages || 0) * u.secPerPage)}
    ` } else if (!u.hd) { body = `
    Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.
    ` } if (u.error) body += `
    Falló algún capítulo: ${esc(u.error)}
    ` const canAdd = KINDS.hd.candidates().length > 0 const actions = [ canAdd ? `` : '', active ? `` : '', u.hd ? `` : '', ].join('') // Los formularios se conservan entre sondeos: solo se repinta la parte de arriba. if (!$('#hdtop')) box.innerHTML = '
    ' $('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `
    ${actions}
    ` : '') + (u.auto ? '
    Los capítulos que se descarguen también se reescalan.
    ' : '') $('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' })) $('#hdcancel')?.addEventListener('click', async () => { if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' })) }) $('#hddel')?.addEventListener('click', async () => { if (!confirm(`¿Quitar la versión HD de «${manga.title}»?\nSe borra mangas/${download?.folder ?? ''}/.hd${u.size ? ` (${fmtSize(u.size)})` : ''}; los capítulos originales se quedan.`)) return renderHd(await api(`/api/upscale/${id}`, { method: 'DELETE' })) toast('Versión HD eliminada') }) if (active) hdTimer = setTimeout(async () => { const next = await api(`/api/upscale/${id}`).catch(() => null); if (next && $('#hd')) renderHd(next) }, 3000) } const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs) hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {} if (changed) refreshSel() } // --- En este dispositivo (sin conexión). const renderOff = () => { const box = $('#off') if (!box || !OFFLINE_OK) return const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id) const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0) const run = offState.running?.mangaId === id ? offState.running : null const queued = offState.queue.filter(q => q.mangaId === id).length markLib('off', !!(run || queued)) box.hidden = false // Siguientes 3 sin guardar a partir de donde vas (o desde el principio). const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id)) const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3) let body = '' if (run || queued) { const pct = run?.total ? (run.done / run.total) * 100 : 0 body = `
    ${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}
    ` } else if (!mine.length) body = '
    Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).
    ' const actions = [ nextIds.length && !run ? `` : '', KINDS.off.candidates().length ? `` : '', mine.length ? `` : '', ].join('') if (!$('#offtop')) box.innerHTML = '
    ' $('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `
    ${actions}
    ` $('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id))) $('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' })) $('#offdel')?.addEventListener('click', async () => { if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return await offRemove(mine.map(([cid]) => cid)) toast('Borrado del dispositivo') }) } let offRefreshAt = 0 const onOff = () => { if (!$('#off')) return renderOff() // La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada). const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0) if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() } } if (OFFLINE_OK) offState.listeners.add(onOff) // --- Listas propias (menú "Listas"). let listSet = new Set(inLists) const renderListsBtn = () => { const b = $('#lbtn') if (!b) return b.classList.toggle('on', listSet.size > 0) b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}` } renderListsBtn() async function renderListsMenu() { const box = $('#lmlist') if (!box) return const lists = await api('/api/lists').catch(() => []) box.innerHTML = lists.map(l => ``).join('') + `${icon('library')} Ver mi librería` box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => { const lid = Number(x.dataset.list) try { const r = x.checked ? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } }) : await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' }) listSet = new Set(r.lists) renderListsBtn() } catch (err) { toast(err.message); x.checked = !x.checked } })) $('#lnew').addEventListener('click', async () => { const name = prompt('Nombre de la nueva lista:') if (!name?.trim()) return try { const l = await api('/api/lists', { method: 'POST', body: { name } }) const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } }) listSet = new Set(r.lists) renderListsBtn() renderListsMenu() toast(`Añadido a «${l.name}»`) } catch (err) { toast(err.message) } }) } $('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() }) const renderFollow = () => { const b = $('#follow') b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir` b.classList.toggle('on', isFollowing) b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos' b.hidden = offline } renderFollow() $('#follow').addEventListener('click', async () => { const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } }) isFollowing = r.following renderFollow() toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga') }) $('#drop')?.addEventListener('click', async () => { if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return await api(`/api/history/${id}`, { method: 'DELETE' }) toast('Quitado del historial') // Volver a donde se estaba (salvo el lector o la propia ficha, que lo volverían a marcar) o al inicio. const back = prevHash && !prevHash.startsWith('#/read/') && !prevHash.startsWith(`#/manga/${id}`) && prevHash !== '#/login' location.hash = back ? prevHash : '#/' }) // Cerrar el desplegable de listas al pulsar fuera. const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false }) document.addEventListener('click', closeMenu) cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); document.removeEventListener('keydown', onLibKey); offState.listeners.delete(onOff) } app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) })) const ul = $('.chapters', app) let desc = store.get('chapterOrderDesc', true) const chapMeta = (c, r, done) => (newSet.has(String(c.num ?? c.id)) ? 'NUEVO ' : '') + (dlSet.has(c.id) ? `${icon('download')} ` : wantedSet.has(c.id) ? `${icon('download')}… ` : '') + (hdSet.has(c.id) ? 'HD ' : hdJobs[c.id] ? `HD${hdJobs[c.id] === 'error' ? '!' : '…'} ` : '') + (OFFLINE_OK && offHas(c.id) ? `${icon('phone')} ` : '') + [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ') // Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si // no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos). let extDates = {} const srcDatesOk = (() => { const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean) const n = {} for (const d of ds) n[d] = (n[d] || 0) + 1 return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25 })() // Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las // traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman. let dateMap = new Map() function buildDates() { const DAY = 864e5 const known = chapters.map(c => { const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null) const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t } }).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n) let min = Infinity for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min } dateMap = new Map() let prev = null known.forEach((x, i) => { if (x.t == null) return dateMap.set(x.c.id, { t: x.t, approx: false }) if (prev && i - prev.i > 1 && i - prev.i <= 150) { for (let k = prev.i + 1; k < i; k++) { const f = (known[k].n - prev.n) / (x.n - prev.n) dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true }) } } prev = { i, n: x.n, t: x.t } }) } buildDates() const chapDate = c => { const e = dateMap.get(c.id) if (!e) return '' const d = new Date(e.t).toISOString().slice(0, 10) return `${e.approx ? '≈ ' : ''}${fmtDate(d)}` } function renderList() { if (!ul) return const list = desc ? [...chapters].reverse() : chapters const picking = sel?.mode === 'pick' const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : []) ul.classList.toggle('picking', picking) ul.innerHTML = list.map((c, i) => { const r = readMap.get(c.id) const done = r && r.total_pages && r.page >= r.total_pages - 1 if (picking) { const ok = pickable.has(c.id) return `
  • ` } const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' return `
  • ${esc(chapLabel(c))}${chapMeta(c, r, done)}
  • ` }).join('') } // Selección en la lista (Mayús + clic marca el tramo desde el último marcado). ul?.addEventListener('click', e => { const box = e.target.closest('input[data-cid]') if (!box) return const i = Number(box.dataset.i) if (e.shiftKey && lastPick != null) { const [a, b] = [Math.min(lastPick, i), Math.max(lastPick, i)] ul.querySelectorAll('input[data-cid]:not(:disabled)').forEach(x => { const k = Number(x.dataset.i) if (k >= a && k <= b) { x.checked = box.checked; box.checked ? picked.add(x.dataset.cid) : picked.delete(x.dataset.cid) } }) } else box.checked ? picked.add(box.dataset.cid) : picked.delete(box.dataset.cid) lastPick = i updateSummary() }) renderList() api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {}) renderDl(download) renderHd(upscale) renderOff() $('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() }) $('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) }) } // ---------- Lector ---------- const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' } // ---------- Sonido al pasar página ---------- // Seis variantes de roce de papel sintetizadas al momento (sin archivos de audio): ruido filtrado cuyo tono // sube con la velocidad de la hoja (el aire que mueve), chasquidos sueltos (las fibras del papel) y un golpe // sordo al caer. Cada vez sale una variante distinta de la anterior, con pequeñas variaciones de duración y // tono para que no suene nunca igual. const TURN_SOUNDS = [ { dur: 0.30, f: [1700, 3600], q: 0.9, peak: 0.55, crackle: 70, thump: 0.25 }, // hoja fina y rápida { dur: 0.50, f: [800, 2100], q: 0.7, peak: 0.6, crackle: 45, thump: 0.6 }, // hoja gruesa, lenta { dur: 0.36, f: [2300, 5000], q: 1.1, peak: 0.5, crackle: 240, thump: 0.15 }, // papel crujiente { dur: 0.42, f: [1200, 2500], q: 0.5, peak: 0.45, crackle: 18, thump: 0 }, // roce suave { dur: 0.50, f: [1400, 3100], q: 0.8, peak: 0.6, crackle: 110, thump: 0.4, twoStep: true }, // se despega y cae { dur: 0.26, f: [2800, 5800], q: 1.4, peak: 0.4, crackle: 160, thump: 0.12, snap: true }, // satinado, con chasquido ] function synthTurn(p, sr, rnd = Math.random) { const n = Math.round(sr * (p.dur + 0.1)) const out = new Float32Array(n) // Filtro paso banda (biquad RBJ, ganancia 1 en el centro). const band = () => { let x1 = 0, x2 = 0, y1 = 0, y2 = 0, b0 = 0, a1 = 0, a2 = 0 return { set(f, q) { const w = 2 * Math.PI * Math.min(f, sr * 0.45) / sr, al = Math.sin(w) / (2 * q), a0 = 1 + al; b0 = al / a0; a1 = -2 * Math.cos(w) / a0; a2 = (1 - al) / a0 }, run(x) { const y = b0 * (x - x2) - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y }, } } const air = band(), fiber = band(), body = band() fiber.set(4200, 0.8) body.set(170, 0.9) const a = Math.log(0.5) / Math.log(p.peak) // sin(π·x^a) tiene su máximo en x = peak const swell = x => x <= 0 || x >= 1 ? 0 : Math.sin(Math.PI * x ** a) ** 2 const env = x => p.twoStep ? Math.max(0.75 * swell(Math.min(1, x / 0.38)), swell((x - 0.25) / 0.75)) : swell(x) let flutter = 0, grain = 0, grainK = 1, snapAt = p.snap ? Math.round(sr * p.dur * 0.12) : -1 const thumpAt = Math.round(sr * p.dur * 0.86), thumpK = Math.exp(-1 / (0.035 * sr)) let thump = 0 for (let i = 0; i < n; i++) { const x = i / (sr * p.dur), e = env(x), w = rnd() * 2 - 1 if (i % 32 === 0) air.set(p.f[0] + (p.f[1] - p.f[0]) * e, p.q) flutter += (w - flutter) * 0.004 // temblor lento del volumen let s = air.run(w) * e * (1 + 1.8 * flutter) // Chasquidos: más frecuentes cuanto más rápido se mueve la hoja. if (rnd() < (p.crackle * (0.15 + e)) / sr) { grain = Math.max(grain, (0.3 + 0.7 * rnd()) * (0.3 + e)); grainK = Math.exp(-1 / ((0.0006 + 0.0025 * rnd()) * sr)) } if (i === snapAt) { grain = 1.6; grainK = Math.exp(-1 / (0.004 * sr)) } grain *= grainK s += fiber.run(w) * grain * 1.4 if (i === thumpAt) thump = p.thump thump *= thumpK s += body.run(w) * thump * 3 out[i] = s } let peak = 0 for (const v of out) peak = Math.max(peak, Math.abs(v)) const fade = Math.round(sr * 0.01) for (let i = 0; i < n; i++) out[i] = (out[i] / (peak || 1)) * Math.min(1, (n - i) / fade) return out } const SOUND_LEVELS = { off: ['Sin sonido', 0], low: ['Sonido bajo', 0.15], mid: ['Sonido medio', 0.3], high: ['Sonido alto', 0.55] } const sfx = { ctx: null, last: -1 } // El contexto de audio solo arranca dentro de un gesto del usuario (iOS): se prepara al tocar el lector. function sfxUnlock() { try { const AC = window.AudioContext || window.webkitAudioContext if (!AC) return null sfx.ctx ??= new AC() if (sfx.ctx.state !== 'running') sfx.ctx.resume().catch(() => {}) return sfx.ctx } catch { return null } } // pan: de -1 (izquierda) a 1 (derecha), el lado por el que se mueve la hoja. function turnSound(level, pan = 0) { const vol = SOUND_LEVELS[level]?.[1] if (!vol || document.hidden) return const ctx = sfxUnlock() if (!ctx) return try { let i do i = Math.floor(Math.random() * TURN_SOUNDS.length); while (i === sfx.last) sfx.last = i const p = TURN_SOUNDS[i], k = 0.88 + Math.random() * 0.24 const data = synthTurn({ ...p, dur: p.dur * (0.88 + Math.random() * 0.24), f: p.f.map(f => f * k) }, ctx.sampleRate) const buf = ctx.createBuffer(1, data.length, ctx.sampleRate) buf.getChannelData(0).set(data) const src = ctx.createBufferSource() src.buffer = buf const gain = ctx.createGain() gain.gain.value = vol * (0.85 + Math.random() * 0.3) src.connect(gain) if (pan && ctx.createStereoPanner) { const sp = ctx.createStereoPanner() sp.pan.value = pan gain.connect(sp).connect(ctx.destination) } else gain.connect(ctx.destination) src.start() } catch {} } // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). function defaultPrefs(manga) { if (['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' } } async function viewReader(mangaId, chapterId, startPage) { document.body.classList.add('reading') app.innerHTML = '
    Cargando capítulo…
    ' const lang = store.get(`lang:${mangaId}`, me.lang) // Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor). const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) } const [info, chap] = await Promise.all([ api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), ]) const { manga, chapters } = info const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) } const idx = chapters.findIndex(c => c.id === chapterId) const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } const prev = idx > 0 ? chapters[idx - 1] : null const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null 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 const lastShown = () => (isPaged() ? shown.at(-1) : page) // Guardado del progreso (con debounce y al salir de la página). let saveTimer = null const payload = () => ({ mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" }) const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } } const onVis = () => document.visibilityState === 'hidden' && beacon() document.addEventListener('visibilitychange', onVis) window.addEventListener('pagehide', beacon) saveNow() const chapOpts = chapters.map(c => ``).join('') app.innerHTML = `
    ${icon('back')}
    ${esc(manga.title)}
    ${prev ? `${icon('prev')} Cap. ant.` : ''} ${next ? `Cap. sig. ${icon('next')}` : ''}
    ` const reader = $('.reader', app) const box = $('#pages', app) const slider = $('#slider', app) $('#mode').value = settings.mode $('#fit').value = settings.fit $('#dir').value = settings.dir // Botón rápido páginas ⇄ vertical (vuelve al último modo por páginas usado). let lastPaged = settings.mode === 'double' ? 'double' : 'paged' function renderModeQuick() { const b = $('#modequick') b.innerHTML = icon(isPaged() ? 'scroll' : 'pages') b.title = isPaged() ? 'Leer en vertical (desplazamiento continuo)' : 'Leer por páginas' $('#mode').value = settings.mode } $('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none' $('#bright').value = settings.brightness const applyFilter = () => { const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim() box.style.setProperty('--page-filter', f || 'none') reader.classList.toggle('bluelight', settings.filter === 'blue') } applyFilter() if ($('#saver')) $('#saver').checked = settings.saver if ($('#hd')) $('#hd').checked = settings.hd !== false const endBlock = `

    Fin del capítulo ${esc(chapter.num ?? '')}

    ${next ? `${icon('next')} Siguiente: ${esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} ${icon('back')} Volver a la ficha
    ` 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 6 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso), ya // decodificadas. Se guardan las referencias para que la descarga no se pierda si se recoge la basura. const preloaded = new Map() function preload() { const want = [] for (let k = 1; k <= 6 && shown.at(-1) + k < total; k++) want.push(shown.at(-1) + k) for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) want.push(shown[0] - k) for (const i of want) { if (preloaded.has(i)) continue const im = new Image() im.onload = () => isWide(im) && wide.add(i) im.src = pages[i] im.decode?.().catch(() => {}) preloaded.set(i, im) } for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i) scheduleLeafTextures() } const imgReady = im => im.complete && im.naturalWidth > 0 // Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que // llegue o pasen 4 s, en vez de pasar la hoja y dejar negro debajo. Si se pide otra página mientras, // manda la última. let showSeq = 0, waitTimer = 0 // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…). function showPaged(dir = 0) { const seq = ++showSeq clearTimeout(waitTimer) box.classList.remove('waiting') finishTurn() const stage = document.createElement('div') stage.className = 'stage' stage.classList.toggle('two', shown.length === 2) stage.innerHTML = shown.map(i => ``).join('') const imgs = [...stage.querySelectorAll('img')] if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) { waitTimer = setTimeout(() => box.classList.add('waiting'), 200) const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {}))) Promise.race([arrived, new Promise(r => setTimeout(r, 4000))]).then(() => { if (seq !== showSeq) return clearTimeout(waitTimer) box.classList.remove('waiting') if (!atEnd && isPaged() && box.isConnected) { grabY = touchedAt try { presentPaged(stage, dir) } finally { grabY = null } } }) return } presentPaged(stage, dir) } function presentPaged(stage, dir) { const old = $('.stage', box) // Lo que aún no ha llegado se ve con un indicador de carga y aparece con un fundido, no de golpe. const imgs = [...stage.querySelectorAll('img')] const pending = imgs.filter(im => !imgReady(im)) pending.forEach(im => { im.classList.add('pending') const done = () => { im.classList.remove('pending') if (!stage.querySelector('img.pending')) stage.classList.remove('loading') } im.addEventListener('load', done, { once: true }) im.addEventListener('error', done, { once: true }) }) stage.classList.toggle('loading', pending.length > 0) // Al arrastrar, el sonido sale al soltar (si la página llega a pasar). if (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 let grabY = null // altura de la pantalla por la que el dedo tocó o agarró la página // Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página). const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12) function finishTurn() { if (!turning) return const t = turning turning = null t.anims.forEach(a => { try { a.cancel() } catch {} }) t.cleanup() } // Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API // que usa finishTurn: cancel() y la promesa finished. function frameAnim(duration, step) { let raf = 0, settle const finished = new Promise((res, rej) => { settle = { res, rej } }) const t0 = performance.now() const tick = now => { const g = Math.min(1, (now - t0) / duration) step(g) if (g < 1) raf = requestAnimationFrame(tick) else settle.res() } step(0) raf = requestAnimationFrame(tick) return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } } } // Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior, // así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso // (la página siguiente en doble página, o papel en blanco) por detrás. // Ángulos de la hoja dividida en N tiras, común a la versión WebGL y a la de capas. function curlCurve(N) { // Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por // delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el // mismo gesto al revés, con el borde libre llegando primero). function angles(g, reverse) { const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2 const bend = Math.sin(Math.PI * g) * 125 return Array.from({ length: N }, (_, i) => { const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45))) return reverse ? 180 - a : a }) } // Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una // sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo. function span(reverse) { const seen = g => angles(g, reverse).some(a => a < 88) let g0 = 0, g1 = 1 while (g0 < 1 && !seen(g0)) g0 += 0.01 while (g1 > g0 && !seen(g1)) g1 -= 0.01 return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)] } // Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo) // y el avance g que la pone donde está el dedo. const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N function solve(target, reverse) { let lo = 0, hi = 1 for (let k = 0; k < 18; k++) { const m = (lo + hi) / 2 if ((edge(m, reverse) > target) !== reverse) lo = m else hi = m } return (lo + hi) / 2 } return { angles, span, solve } } // --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl // (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU. const LEAF_COLS = 32, LEAF_ROWS = 8 let leafGL = null, leafGLOff = false, leafTimer = 0 const leafTextures = new Map() // URL de la página → textura const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…) function initLeafGL() { if (leafGL || leafGLOff) return leafGL const canvas = document.createElement('canvas') canvas.className = 'curlgl' const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true, depth: true, powerPreference: 'high-performance' }) const shader = (type, src) => { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); return sh } const prog = gl?.createProgram() if (gl) { // Misma proyección que perspective en CSS: el origen en el centro y la escala 1 / (1 - z / perspectiva). gl.attachShader(prog, shader(gl.VERTEX_SHADER, `#version 300 es uniform vec3 uView; layout(location = 0) in vec4 aPos; layout(location = 1) in vec2 aUV; out vec2 vUV; out float vC; void main() { float w = 1.0 - aPos.z / uView.z; gl_Position = vec4(aPos.x / uView.x * 2.0 - 1.0, 1.0 - aPos.y / uView.y * 2.0, 1.05128 * w - 0.10256, w); vUV = aUV; vC = aPos.w; }`)) // La cara se oscurece al inclinarse. El sesgo negativo evita que la página se vea blanda con los mipmaps. gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, `#version 300 es precision highp float; uniform sampler2D uFront, uBack; uniform bool uPaper; in vec2 vUV; in float vC; out vec4 color; void main() { vec4 t; float b; if (gl_FrontFacing) { t = texture(uFront, vUV, -0.5); b = 1.0 - (1.0 - vC) * 0.3; } else { t = uPaper ? vec4(0.914, 0.898, 0.863, 1.0) : texture(uBack, vec2(1.0 - vUV.x, vUV.y), -0.5); b = 1.0 - (1.0 + vC) * 0.3; } color = vec4(t.rgb * b, t.a); }`)) gl.linkProgram(prog) } if (!gl || !gl.getProgramParameter(prog, gl.LINK_STATUS)) { leafGLOff = true; return null } gl.useProgram(prog) gl.uniform1i(gl.getUniformLocation(prog, 'uFront'), 0) gl.uniform1i(gl.getUniformLocation(prog, 'uBack'), 1) gl.enable(gl.DEPTH_TEST) gl.depthFunc(gl.LEQUAL) gl.clearColor(0, 0, 0, 0) const count = (LEAF_COLS + 1) * (LEAF_ROWS + 1) const posBuf = gl.createBuffer(), uvBuf = gl.createBuffer(), indexBuf = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, posBuf) gl.bufferData(gl.ARRAY_BUFFER, count * 16, gl.DYNAMIC_DRAW) gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0) gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf) gl.bufferData(gl.ARRAY_BUFFER, count * 8, gl.DYNAMIC_DRAW) gl.enableVertexAttribArray(1) gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0) const at = (c, f) => c * (LEAF_ROWS + 1) + f const index = new Uint16Array(LEAF_COLS * LEAF_ROWS * 6) let n = 0 for (let c = 0; c < LEAF_COLS; c++) for (let f = 0; f < LEAF_ROWS; f++) { index.set([at(c, f), at(c, f + 1), at(c + 1, f), at(c + 1, f), at(c, f + 1), at(c + 1, f + 1)], n) n += 6 } gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true) const aniso = gl.getExtension('EXT_texture_filter_anisotropic') // Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro. canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } }) leafGL = { canvas, gl, posBuf, uvBuf, aniso, positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n, uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) } return leafGL } function leafTexture(im) { const known = leafTextures.get(im.src) if (known) return known const { gl, aniso, maxSize } = leafGL if (Math.max(im.naturalWidth, im.naturalHeight) > maxSize) throw new Error('página demasiado grande') const tex = gl.createTexture() gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, tex) try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im) gl.generateMipmap(gl.TEXTURE_2D) if (gl.getError() !== gl.NO_ERROR) throw new Error('sin memoria para la textura') } catch (e) { gl.deleteTexture(tex); throw e } gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))) leafTextures.set(im.src, tex) return tex } // Sube con antelación, en reposo y de una en una, las páginas de los giros posibles: subirla al empezar el // giro daría un tirón. Una página que no llegó a subirse se sube al girar. function scheduleLeafTextures(delay = 300) { clearTimeout(leafTimer) if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay) } function nextLeafTexture() { if (turning) return scheduleLeafTextures(400) const n = shown.length const near = [] for (let k = 1; k <= n; k++) near.push(shown.at(-1) + k, shown[0] - k) const wanted = [...box.querySelectorAll('.stage img'), ...near.map(i => preloaded.get(i)).filter(Boolean)] const keep = new Set(wanted.map(im => im.src)) for (const [src, tex] of leafTextures) if (!keep.has(src)) { leafGL?.gl.deleteTexture(tex); leafTextures.delete(src) } const missing = wanted.filter(im => !leafTextures.has(im.src) && !leafNoTexture.has(im.src)) const im = missing.find(imgReady) if (!im) { if (missing.length) scheduleLeafTextures(800); return } if (!initLeafGL()) return try { leafTexture(im) } catch { leafNoTexture.add(im.src) } scheduleLeafTextures(120) } function freeLeafGL() { leafGLOff = true clearTimeout(leafTimer) leafGL?.gl.getExtension('WEBGL_lose_context')?.loseContext() leafGL = null leafTextures.clear() } // Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl. function glCurl(r, frontIm, backIm, spineLeft) { if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null const L = leafGL const { gl, canvas, positions, uvs } = L let front, back try { front = leafTexture(frontIm) back = backIm ? leafTexture(backIm) : null } catch { leafNoTexture.add(frontIm.src); if (backIm) leafNoTexture.add(backIm.src); return null } const W = box.clientWidth, H = box.clientHeight, N = LEAF_COLS, R = LEAF_ROWS const pw = Math.round(W * devicePixelRatio), ph = Math.round(H * devicePixelRatio) if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph } Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` }) gl.viewport(0, 0, pw, ph) gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200) gl.uniform1i(L.uPaper, back ? 0 : 1) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, front) gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, back) // Las columnas van del lomo al borde libre: con el lomo a la derecha, la malla queda al revés. gl.frontFace(spineLeft ? gl.CCW : gl.CW) for (let c = 0, n = 0; c <= N; c++) for (let f = 0; f <= R; f++) { uvs[n++] = spineLeft ? c / N : 1 - c / N uvs[n++] = f / R } gl.bindBuffer(gl.ARRAY_BUFFER, L.uvBuf) gl.bufferSubData(gl.ARRAY_BUFFER, 0, uvs) gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf) const curve = curlCurve(N) const step = (spineLeft ? r.w : -r.w) / N const sgn = spineLeft ? -1 : 1 const spineX = r.l + (spineLeft ? 0 : r.w) let pivotY = r.h / 2 const rad = Math.PI / 180 const mul = (a, b) => [ a[0] * b[0] + a[1] * b[3] + a[2] * b[6], a[0] * b[1] + a[1] * b[4] + a[2] * b[7], a[0] * b[2] + a[1] * b[5] + a[2] * b[8], a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8], a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8], ] // Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo. Encadena los giros de tira en // tira como lo hacen las capas anidadas de paperCurl. function set(g, reverse, tiltZ = 0, tiltX = 0) { const a = curve.angles(g, reverse) let m = [1, 0, 0, 0, 1, 0, 0, 0, 1], x = 0, y = 0, z = 0, prev = 0, prevC = 1, lift = 0, n = 0 const column = c => { for (let f = 0; f <= R; f++) { const v = (r.h * f) / R - pivotY positions[n++] = spineX + x + m[1] * v positions[n++] = r.t + pivotY + y + m[4] * v positions[n++] = z + m[7] * v positions[n++] = c } } for (let i = 0; i < N; i++) { const c = Math.cos(a[i] * rad) if (i) { x += m[0] * step; y += m[3] * step; z += m[6] * step } const t = sgn * (a[i] - prev) * rad m = mul(m, [Math.cos(t), 0, Math.sin(t), 0, 1, 0, -Math.sin(t), 0, Math.cos(t)]) if (i && (tiltZ || tiltX)) { const tz = ((tiltZ * c) / (N - 1)) * rad, tx = ((tiltX * c) / (N - 1)) * rad m = mul(mul(m, [Math.cos(tz), -Math.sin(tz), 0, Math.sin(tz), Math.cos(tz), 0, 0, 0, 1]), [1, 0, 0, 0, Math.cos(tx), -Math.sin(tx), 0, Math.sin(tx), Math.cos(tx)]) } column(i ? (c + prevC) / 2 : c) prev = a[i] prevC = c lift = Math.max(lift, Math.sin(a[i] * rad)) } x += m[0] * step; y += m[3] * step; z += m[6] * step column(prevC) gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions) gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0) return lift } const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 } return { el: canvas, gl: true, set, span: curve.span, solve: curve.solve, pivot } } function paperCurl(r, frontSrc, backSrc, spineLeft, bent = false) { const N = Math.max(10, Math.min(22, Math.round(r.w / 24))) const w = r.w / N // Cada cara se sale OV px por los lados de su tira para tapar la unión con la vecina: sin solape, // Safari deja una rendija oscura entre tiras. // Curvada, las tiras dejan de encajar justas lejos del punto fijo: hace falta más solape. const OV = bent ? 4 : 1.5 const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira const el = document.createElement('div') el.className = 'curl' const strips = [] let parent = el for (let i = 0; i < N; i++) { const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta const s = document.createElement('div') s.className = 'strip' Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' }) // Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última // columna). El reverso está girado sobre su centro: allí su izquierda es la derecha de la tira. const l = fc > 0 ? OV : 0, rr = fc < N - 1 ? OV : 0 const face = (cls, src, col, off) => `
    ${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 const { angles, span, solve } = curlCurve(N) // Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo. // tiltZ/tiltX: curvatura total de la hoja agarrada por arriba o por abajo. Se reparte entre las tiras salvo la // del lomo, que no se curva, y el borde libre se lleva todo: la hoja sigue pegada al libro. Cambia de // signo con la cara visible para que la parte agarrada vaya siempre por delante. function set(g, reverse, tiltZ = 0, tiltX = 0) { let prev = 0, lift = 0 angles(g, reverse).forEach((a, i) => { const t = strips[i] const c = Math.cos((a * Math.PI) / 180) const bend = i && (tiltZ || tiltX) ? ` rotateZ(${((tiltZ * c) / (N - 1)).toFixed(3)}deg) rotateX(${((tiltX * c) / (N - 1)).toFixed(3)}deg)` : '' t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)${bend}` prev = a // Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari // cada capa se compone aparte y en las uniones entre tiras asomaban líneas. t.f.style.filter = `brightness(${(1 - (1 - c) * 0.3).toFixed(3)})` t.b.style.filter = `brightness(${(1 - (1 + c) * 0.3).toFixed(3)})` lift = Math.max(lift, Math.sin((a * Math.PI) / 180)) }) return lift } // Punto fijo de la curvatura: el borde opuesto al agarrado (no afecta al giro sobre el eje vertical). const pivot = y => strips.forEach(t => { t.s.style.transformOrigin = `${spineLeft ? 'left' : 'right'} ${y}` }) return { el, set, span, solve, pivot } } const GRAB_TILT_Z = 14, GRAB_TILT_X = 10 // curvatura total, en grados, de la hoja agarrada por arriba o por abajo // drag: la página sigue al dedo; devuelve el control (move/release) en vez de animarse sola. function turnPage(old, stage, dir, drag = false) { // "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq. const sign = (settings.dir === 'rtl' ? -1 : 1) * dir const fitWidth = box.classList.contains('fit-width') 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, frontIm, backIm = null, spineLeft, reverse = false if (double) { // Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es // la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia // encima de la nueva) hasta que la hoja cae. const oldX = byX(oldImgs) // Las nuevas puede que aún no estén cargadas (sin tamaño ni posición): su lado sale del orden de lectura. const newX = [...stage.querySelectorAll('img')] if (settings.dir === 'rtl') newX.reverse() const front = sign > 0 ? oldX.at(-1) : oldX[0] const stay = sign > 0 ? oldX[0] : oldX.at(-1) rect = rel(front) frontIm = front backIm = sign > 0 ? newX[0] : newX.at(-1) 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) frontIm = img 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 grabAt = grabY != null ? (grabY - B.top - rect.t) / rect.h : 0.5 const grab = grabAt < 1 / 3 ? -1 : grabAt > 2 / 3 ? 1 : 0 const curl = glCurl(rect, frontIm, backIm, spineLeft) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab) if (!curl.gl) place(curl.el, rect) box.append(drop, curl.el) extra.push(drop, curl.el) ;[g0, g1] = double ? [0, 1] : curl.span(reverse) // Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva // sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta. // Por el centro, como siempre. if (grab) curl.pivot(grab < 0 ? 'bottom' : 'top') step = g => { const k = grab ? grab * Math.sin(Math.PI * Math.min(1, Math.max(0, (g - g0) / (g1 - g0 || 1)))) : 0 const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k) drop.style.opacity = (lift * 0.8).toFixed(3) } dur = double ? 720 : 580 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() showSeq++ // descarta una página que estuviera esperando a cargar clearTimeout(waitTimer) box.classList.remove('waiting') 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) } // En der → izq, la barra de abajo va como la lectura: el capítulo siguiente a la izquierda. function syncDirUi() { const rtl = isPaged() && settings.dir === 'rtl' slider.dir = rtl ? 'rtl' : 'ltr' $('.rbar.bottom').classList.toggle('rtl', rtl) } 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). syncDirUi() $('#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 } // La hoja se levanta por la altura del toque, igual que al arrastrarla. grabY = cy try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null } } box.addEventListener('pointerdown', e => { if (!isPaged() || atEnd || 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 grabY = tap.y try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null } if (turning?.move) { gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp } 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; syncDirUi(); savePrefs() }) $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() }) $('#anim').value = settings.anim $('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() }) $('#sfx').value = SOUND_LEVELS[settings.sfx] ? settings.sfx : 'mid' $('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) }) $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() }) $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() }) $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, 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 = () => { freeLeafGL() endCrop() if (saveTimer) saveNow() observer?.disconnect() clearTimeout(uiTimer) document.removeEventListener('visibilitychange', onVis) window.removeEventListener('pagehide', beacon) document.removeEventListener('keydown', onKey) window.removeEventListener('scroll', onScroll) window.removeEventListener('resize', onResize) } } // ---------- Recortes ---------- const clipStatus = c => c.status === 'done' ? `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 = `

    ${icon('scissors')} Recortes

    ${data.mangas.length > 1 || mangaId ? `
    ${data.mangas.map(m => ``).join('')}
    ` : ''}
    ` app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' })) const grid = $('#cgrid'), lb = $('#lb') let open = -1, showHd = true function renderGrid() { grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
    ${esc(c.mangaTitle)}${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)} ${c.note ? `${esc(c.note)}` : ''}
    `).join('') : `

    Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.

    ` grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i)))) } function openClip(i) { open = i const c = data.clips[i] if (!c) return closeClip() const src = c.hd && showHd ? c.hd : c.img const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}` lb.innerHTML = `
    ${i + 1} / ${data.clips.length}

    ${esc(c.mangaTitle)}

    ${esc(c.chapterLabel || '')} · página ${c.page + 1}
    ${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}
    ${c.status === 'error' ? `

    ${esc(c.error || 'Error al reescalar')}

    ` : ''} ${c.status === 'off' ? '

    Reescalado no disponible en este servidor.

    ' : ''} ${icon('book')} Ir a la página ${c.hd ? `` : ''}
    ${icon('download')} Descargar ${c.status === 'error' || (c.status === 'off' && data.available) ? `` : ''}
    ` lb.hidden = false document.body.classList.add('noscroll') lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1)) lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1)) lb.querySelector('[data-a=close]').addEventListener('click', closeClip) $('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) }) $('#note', lb).addEventListener('change', async e => { try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) } }) lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => { try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) } }) lb.querySelector('[data-a=del]').addEventListener('click', async () => { if (!confirm('¿Eliminar este recorte?')) return try { await api(`/api/clips/${c.id}`, { method: 'DELETE' }) data.clips.splice(open, 1) renderGrid() data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip() } catch (err) { toast(err.message) } }) } function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') } lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() }) const onKey = e => { if (open < 0 || e.target.matches('input')) return if (e.key === 'Escape') closeClip() else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1) else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1) } document.addEventListener('keydown', onKey) // Mientras haya recortes en cola, refrescar su estado. let timer = null function poll() { clearTimeout(timer) if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return timer = setTimeout(async () => { if (!here()) return const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null) if (!fresh || !here()) return const key = d => d.clips.map(c => `${c.id}:${c.status}`).join() if (key(fresh) !== key(data)) { const openId = data.clips[open]?.id data = fresh renderGrid() if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() } } poll() }, 3000) } renderGrid() poll() cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') } } // ---------- Librería ---------- // Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias. const libCard = ({ href, cover, title, sub, pct, badge, del }) => ` ${badge ? `${badge}` : ''} ${del ? `` : ''}
    ${esc(title)}
    ${sub ? `
    ${sub}
    ` : ''}
    ` async function viewLibrary(params) { const tab = params.get('tab') || 'reading' app.innerHTML = '
    Cargando…
    ' const [hist, fol, lists] = serverReachable ? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])]) : [null, null, []] if (!location.hash.startsWith('#/library')) return const online = !!hist const dev = OFFLINE_OK ? offByManga() : [] const reading = (hist || []).filter(h => !h.finished) const finished = (hist || []).filter(h => h.finished) const follows = fol?.follows || [] 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]), ] const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null app.innerHTML = `

    Librería

    ${tabs.map(([k, label, ic, n]) => ``).join('')} ${online ? `` : ''}
    ${curList ? `
    Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas
    ` : ''}
    Cargando…
    ` app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` })) const body = $('#libbody') const grid = (cards, empty) => (cards.length ? `
    ${cards.join('')}
    ` : `

    ${empty}

    `) const histCard = h => libCard({ href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title, pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0, badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '', sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`, del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' }, }) if (!online && tab !== 'device') { body.innerHTML = `

    Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}

    ` + (dev.length ? `

    ${icon('phone')} En el dispositivo

    ` : '') } 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 = '' }) => `
    ${badge ? `${badge}` : ''}${del}
    ${esc(title)}
    ${lines}
    ${actions ? `
    ${actions}
    ` : ''}
    ` // 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)}.` : ''}

    ${groups.size ? `
    ${[...groups.values()].map((g, i) => { const cur = offState.running?.mangaId === g.mangaId ? offState.running : null const rest = g.chapters.length - (cur ? 1 : 0) return qcard({ key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle, pct: cur?.total ? (cur.done / cur.total) * 100 : null, badge: cur ? icon('phone') : i + 1, lines: `${cur ? `
    ${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}
    ` : ''} ${rest ? `
    ${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola
    ` : ''}`, actions: ``, }) }).join('')}
    ` : '

    No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.

    '}` } const html = q.offline ? `

    Colas

    ${esc(q.offline)}: no se pueden ver las colas del servidor.

    ${devHtml()}
    ` : `

    Colas

    ${icon('download')} Descargas ${q.downloads.length ? `${q.downloads.length}` : ''}

    ${q.downloads.length ? `
    ${q.downloads.map((d, i) => qcard({ key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d), badge: d.status === 'downloading' ? icon('download') : i + 1, lines: `
    ${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%
    ${d.status === 'downloading' ? `
    ${downloadDetail(d)}
    ` : ''}${d.scope === 'some' ? '
    Capítulos elegidos
    ' : ''}`, actions: ``, })).join('')}
    ` : '

    No hay descargas en curso. Se añaden desde la ficha de un manga.

    '}
    ${OFFLINE_OK ? `
    ${devHtml()}
    ` : ''}

    ${icon('sparkles')} Reescalado HD ${hdQueued ? `${hdQueued}` : ''}

    ${up.available ? '' : '

    El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).

    '} ${up.groups.length ? `${hdQueued ? `

    Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}

    ` : ''}
    ${up.groups.map((g, i) => { const cur = up.current?.mangaId === g.mangaId ? up.current : null const rest = g.queued - (cur ? 1 : 0) const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : '' return qcard({ key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title, pct: cur?.total ? (cur.pages / cur.total) * 100 : null, badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!', lines: `${cur ? `
    Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}
    ` : ''} ${g.queued ? `
    ${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}
    ${fmtDur(g.eta)}
    ` : ''} ${g.errors.length ? `
    ${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}
    ` : ''}`, actions: `${g.errors.length ? `` : ''} ${g.queued ? `` : ''}`, }) }).join('')}
    ` : up.available ? '

    No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.

    ' : ''} ${extra.length ? `
    ${extra.join('')}
    ` : ''}

    ${icon('book')} Biblioteca descargada

    CBZ en /zdata/media/mangas (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: ${fmtSize(total)}.` : ''}

    ${library.length ? `
    ${library.map(d => qcard({ key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null, lines: `
    ${esc(downloadLabel(d))}
    ${fmtSize(d.size)}
    ${d.status === 'error' ? `
    Con errores
    ` : ''}`, del: ``, })).join('')}
    ` : `

    ${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no tienes nada descargado. Usa “Descargar” en la ficha de un manga; lo que ya descargó otro usuario aparece aquí en cuanto lo empiezas a leer.'}

    `}
    ` // 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) { const shared = !!b.dataset.shared if (!confirm(shared ? `¿Quitar «${b.dataset.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan.` : `¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), shared ? 'Quitado de tu biblioteca' : 'Copia local eliminada') } }) } else { const tpl = document.createElement('div') tpl.innerHTML = html morph(wrap, tpl, true) } // Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo). const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000) // El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página. let raf = 0 const onDev = () => { if (raf) return raf = requestAnimationFrame(() => { raf = 0 const box = $('#devq') if (!box || !here()) return const tpl = document.createElement('section') tpl.innerHTML = devHtml() morph(box, tpl, true) }) } offState.listeners.add(onDev) cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) } } // ---------- Estadísticas ---------- const fmtNum = n => n.toLocaleString('es') const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' }) const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) } // Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra. function barChart(data, { value, tip, label }, W = 560) { const H = 190, L = 34, B = 24, T = 8 const max = niceMax(Math.max(1, ...data.map(value))) const cw = (W - L) / data.length const bw = Math.max(4, Math.min(28, cw - 6)) const y = v => T + (H - T - B) * (1 - v / max) const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max] const grid = ticks.map(v => `${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 `${grid}${bars}` } 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í.

    ' return } const maxDay = Math.max(1, ...st.calendar.map(d => d.pages)) const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5))) const weekLabel = w => fmtDay(w.start) const tile = (value, label, sub = '') => `
    ${value}
    ${label}
    ${sub ? `
    ${sub}
    ` : ''}
    ` // Calendario: columnas = semanas (lunes arriba). const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7 const cells = [...Array(firstDow).fill(''), ...st.calendar.map(d => ``)].join('') const gmax = Math.max(1, ...st.genres.map(g => g.pages)) app.innerHTML = `

    ${icon('chart')} Estadísticas

    ${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')} ${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')} ${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)} ${tile(fmtNum(t.chapters), 'Capítulos terminados')} ${tile(fmtNum(t.pages), 'Páginas leídas')} ${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)}

    Capítulos terminados por semana

    Páginas leídas por semana

    Actividad (últimas 18 semanas)

    ${cells}
    Menos Más páginas

    Géneros favoritos

    ${st.genres.length ? `
    ${st.genres.map(g => `
    ${esc(g.name)}${g.pct}%
    `).join('')}

    Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).

    ` : '

    Abre la ficha de los mangas que lees para registrar sus géneros.

    '}

    Lo más leído

    Ver datos por semana ${[...st.weeks].reverse().map(w => ``).join('')}
    Semana delCapítulosPáginas
    ${fmtDay(w.start)}${w.chapters}${fmtNum(w.pages)}
    ` // 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ítulos
    Cambiar contraseña
    ${icon('download')} Descarga anticipada

    Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).

    ${icon('phone')} App y lectura sin conexión ${OFFLINE_OK ? `

    Capítulos guardados en este dispositivo: …. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.

    ${icon('library')} Ver lo guardado
    ` : `

    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:

    ntfy Telegram (opcional)

    Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.

    ` api('/api/notify').then(n => { const f = $('#notify') if (!f) return f.ntfyUrl.value = n.ntfyUrl f.ntfyTopic.value = n.ntfyTopic f.tgChat.value = n.tgChat if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)' if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)' }).catch(() => {}) $('#notify').addEventListener('submit', async e => { e.preventDefault() const f = e.target try { await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) }) toast('Avisos guardados') viewSettings() } catch (err) { toast(err.message) } }) const renderBans = async () => { const b = await api('/api/bans').catch(() => null) const box = $('#bans') if (!box || !b) return box.innerHTML = `

    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.

    ${b.bans.length ? `
    ${b.bans.map(x => `
    ${esc(x.ip)}hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')}
    `).join('')}
    ` : '

    Ninguna IP bloqueada.

    '}

    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)}` : ''}

    ${b.fails.length ? `

    Intentos fallidos recientes: ${b.fails.map(f => `${esc(f.ip)} ×${f.n}`).join(', ')}

    ` : ''}` box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => { try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) } })) } renderBans() api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {}) const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message)) $('#pfn').addEventListener('change', savePf) $('#pfhd').addEventListener('change', savePf) if (OFFLINE_OK) { const dev = offByManga() $('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno' $('#offclear').hidden = !dev.length $('#offclear').addEventListener('click', async () => { if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return await caches.delete(OFF_CACHE) store.set('offlineChapters', {}) toast('Borrado') viewSettings() }) const inst = $('#install') inst.hidden = !installPrompt inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true }) } $('#ntest').addEventListener('click', async () => { try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) } }) $('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() }) $('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') }) $('#pw').addEventListener('submit', async e => { e.preventDefault() try { await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } }) e.target.reset(); toast('Contraseña cambiada') } catch (err) { toast(err.message) } }) }