Entre las dos caras de una pareja la vista se recorre, con las páginas unidas por el lomo. Entre parejas gira la hoja, con la página nueva en el reverso, la siguiente debajo y la vista acompañando al giro. La hendidura alterna de lado según la cara, y las páginas anchas ya no la llevan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3393 lines
199 KiB
JavaScript
3393 lines
199 KiB
JavaScript
const app = document.getElementById('app')
|
||
// Iconos SVG en línea (trazo estilo Lucide), heredan el color del texto.
|
||
const ICONS = {
|
||
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/>',
|
||
history: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>',
|
||
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
|
||
settings: '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||
play: '<path d="M7 4v16l13-8z"/>',
|
||
book: '<path d="M2 5h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 5h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
|
||
stopRead: '<circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/>',
|
||
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
|
||
trash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 15h10l1-15"/><path d="M10 11v6M14 11v6"/>',
|
||
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4L21 8"/><path d="M21 3v5h-5"/>',
|
||
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||
back: '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
|
||
prev: '<path d="m15 18-6-6 6-6"/>',
|
||
next: '<path d="m9 18 6-6-6-6"/>',
|
||
more: '<path d="m6 9 6 6 6-6"/>',
|
||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
|
||
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
|
||
userPlus: '<circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0"/><path d="M19 8v6M16 11h6"/>',
|
||
flame: '<path d="M12 22c3.9 0 7-2.8 7-7 0-3.5-2.4-5.6-3.6-8.6-.9 1.8-1.9 2.9-3 3.2.2-3.1-.9-5.6-3-7.6.3 4-4.4 6.8-4.4 12.6C5 19.2 8.1 22 12 22z"/>',
|
||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||
wifiOff: '<path d="M2 2l20 20"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 5.2-2.8"/><path d="M14.4 10.2A10 10 0 0 1 19 12.9"/><path d="M2 8.8a15 15 0 0 1 4.2-2.7"/><path d="M10.7 5A15 15 0 0 1 22 8.8"/><path d="M12 20h.01"/>',
|
||
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3"/>',
|
||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/>',
|
||
bellOff: '<path d="M8.7 3A6 6 0 0 1 18 8a21 21 0 0 0 .6 5"/><path d="M17 17H3s3-2 3-9a4.7 4.7 0 0 1 .3-2"/><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0"/><path d="m2 2 20 20"/>',
|
||
chart: '<path d="M3 3v18h18"/><path d="M7 16v-4M12 16V8M17 16v-7"/>',
|
||
zoomOut: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
|
||
send: '<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>',
|
||
dots: '<circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/>',
|
||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
|
||
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
|
||
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
|
||
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
|
||
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
|
||
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
|
||
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
|
||
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
|
||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
|
||
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
|
||
}
|
||
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
|
||
// Iconos del menú superior (index.html).
|
||
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
|
||
|
||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
|
||
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')}<span><b>${NO_SERVER}.</b> ${OFFLINE_OK ? 'Puedes leer lo guardado en este dispositivo; ' : ''}se reconecta solo.</span>`
|
||
: !serverOnline ? `${icon('wifiOff')}<span><b>El servidor no tiene internet.</b> Puedes leer lo descargado en el servidor; se reconecta solo.</span>` : ''
|
||
if (!msg) return b?.remove()
|
||
if (!b) { b = document.createElement('div'); b.id = 'netbanner'; $('#topbar').after(b) }
|
||
if (b.innerHTML !== msg) b.innerHTML = msg
|
||
}
|
||
function setServerReachable(ok) {
|
||
if (ok === serverReachable) return
|
||
serverReachable = ok
|
||
netBanner()
|
||
if (ok) { flushProgress(); refreshNet() }
|
||
if (isHome()) router()
|
||
}
|
||
async function refreshNet() {
|
||
const st = await pingServer()
|
||
netChecked = true
|
||
if (!st) return setServerReachable(false)
|
||
const changed = !serverReachable || st.online !== serverOnline
|
||
serverOnline = st.online !== false
|
||
if (!serverReachable) return setServerReachable(true) // ya redibuja y vuelve a llamar
|
||
netBanner()
|
||
if (changed && isHome()) router()
|
||
}
|
||
setInterval(() => { if (serverReachable) refreshNet() }, 20e3)
|
||
setInterval(() => { if (!serverReachable) refreshNet() }, 10e3)
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshNet() })
|
||
function timeAgo(ts) {
|
||
const s = (Date.now() - ts) / 1000
|
||
if (s < 60) return 'hace un momento'
|
||
if (s < 3600) return `hace ${Math.floor(s / 60)} min`
|
||
if (s < 86400) return `hace ${Math.floor(s / 3600)} h`
|
||
return `hace ${Math.floor(s / 86400)} d`
|
||
}
|
||
const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '')
|
||
const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}`
|
||
|
||
// ---------- Sin conexión (capítulos guardados en este dispositivo) ----------
|
||
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
|
||
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
|
||
const OFF_CACHE = 'offline-v1'
|
||
// Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL.
|
||
if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {})
|
||
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
|
||
const offHas = id => !!offIdx()[id]
|
||
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
|
||
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
|
||
// Capítulos guardados agrupados por manga.
|
||
function offByManga() {
|
||
const g = new Map()
|
||
for (const [chapterId, c] of Object.entries(offIdx())) {
|
||
const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 }
|
||
m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at)
|
||
g.set(c.mangaId, m)
|
||
}
|
||
return [...g.values()].sort((a, b) => b.at - a.at)
|
||
}
|
||
// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }]
|
||
function offEnqueue(items) {
|
||
for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it)
|
||
navigator.storage?.persist?.().catch(() => {})
|
||
offEmit()
|
||
offRun()
|
||
}
|
||
async function offRun() {
|
||
if (offState.running) return
|
||
while (offState.queue.length) {
|
||
const it = offState.queue.shift()
|
||
offState.running = { ...it, done: 0, total: 0 }
|
||
offEmit()
|
||
try { await offSaveChapter(it) } catch (err) { if (!offState.cancelled.has(it.chapterId)) toast(`No se pudo guardar ${it.label}: ${err.message}`) }
|
||
offState.cancelled.delete(it.chapterId)
|
||
}
|
||
offState.running = null
|
||
offEmit()
|
||
}
|
||
// Cancela lo que quede por guardar de un manga (o todo): lo pendiente sale de la cola y el capítulo en
|
||
// curso se interrumpe y se borra lo que ya se había guardado de él.
|
||
function offCancel(mangaId = null) {
|
||
offState.queue = offState.queue.filter(q => mangaId && q.mangaId !== mangaId)
|
||
if (offState.running && (!mangaId || offState.running.mangaId === mangaId)) {
|
||
offState.cancelled.add(offState.running.chapterId)
|
||
offState.abort?.abort() // corta las páginas que se estén bajando
|
||
}
|
||
offEmit()
|
||
}
|
||
async function offSaveChapter(it) {
|
||
const cache = await caches.open(OFF_CACHE)
|
||
const saved = []
|
||
offState.abort = new AbortController()
|
||
try { await offSaveInto(cache, it, saved, offState.abort.signal) } catch (err) {
|
||
await Promise.all(saved.map(u => cache.delete(u, { ignoreSearch: false }))) // nada de capítulos a medias
|
||
throw err
|
||
}
|
||
}
|
||
async function offSaveInto(cache, it, saved, signal) {
|
||
const stop = () => { if (offState.cancelled.has(it.chapterId)) throw new Error('cancelado') }
|
||
const hd = store.get('readerSettings', {}).hd !== false
|
||
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
|
||
const res = await fetch(chapUrl, { signal })
|
||
if (!res.ok) throw new Error(`Error ${res.status}`)
|
||
const data = await res.clone().json()
|
||
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 = `<p class="error">${esc(e.message)}</p>`
|
||
}
|
||
}
|
||
window.addEventListener('hashchange', router)
|
||
router()
|
||
|
||
// ---------- Login / registro ----------
|
||
function viewAuth(signup) {
|
||
app.innerHTML = `
|
||
<div class="auth">
|
||
<h1>Manga Reader</h1>
|
||
<p class="muted">Inicia sesión para leer y guardar tu progreso.</p>
|
||
<form>
|
||
<input type="text" name="username" placeholder="Usuario" autocomplete="username" required>
|
||
<input type="password" name="password" placeholder="Contraseña" autocomplete="current-password" required>
|
||
<p class="error"></p>
|
||
<button class="btn primary" value="login">${icon('login')} Entrar</button>
|
||
${signup ? `<button class="btn" value="register">${icon('userPlus')} Crear cuenta</button>` : ''}
|
||
</form>
|
||
</div>`
|
||
const form = $('form', app)
|
||
form.addEventListener('submit', async ev => {
|
||
ev.preventDefault()
|
||
const mode = ev.submitter?.value === 'register' ? 'register' : 'login'
|
||
const fd = new FormData(form)
|
||
try {
|
||
await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } })
|
||
me = null
|
||
location.hash = '#/'
|
||
router()
|
||
} catch (e) { $('.error', form).textContent = e.message }
|
||
})
|
||
}
|
||
|
||
// ---------- Inicio ----------
|
||
function mangaCard(m) {
|
||
return `<a class="card" href="#/manga/${m.id}">
|
||
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="" data-fallback>
|
||
<div class="t">${esc(m.title)}</div>${m.sub ? `<div class="sub">${esc(m.sub)}</div>` : ''}</a>`
|
||
}
|
||
function continueCard(h) {
|
||
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
|
||
// Terminado con capítulos nuevos: a la ficha, para elegir el siguiente.
|
||
return `<a class="cont" href="${h.hasNew ? `#/manga/${h.manga_id}` : readUrl(h.manga_id, h.chapter_id, h.page)}">
|
||
<img src="${esc(h.cover || '/icon.svg')}" alt="" loading="lazy" data-fallback>
|
||
<div class="info"><b>${esc(h.manga_title)}</b>
|
||
<div class="muted">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}</div>
|
||
${h.finished ? '<div><span class="badge">Terminado</span></div>' : h.hasNew ? '<div><span class="badge">Caps. nuevos</span></div>' : ''}
|
||
<div class="muted" style="font-size:.8rem">${timeAgo(h.updated_at)}</div>
|
||
<div class="bar"><i style="width:${pct}%"></i></div></div></a>`
|
||
}
|
||
// Sección "Novedades": mangas seguidos con capítulos nuevos.
|
||
function newsSection(fol) {
|
||
const fresh = fol.follows.filter(f => f.newCount > 0)
|
||
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
|
||
if (!fol.follows.length) return ''
|
||
return `<div class="row" style="justify-content:space-between;margin-top:8px">
|
||
<h2 style="margin:12px 0">${icon('sparkles')} Novedades ${fresh.length ? `<span class="badge">${fresh.reduce((n, f) => n + f.newCount, 0)}</span>` : ''}</h2>
|
||
<span class="row muted" style="font-size:.85rem">Sigues ${fol.follows.length} manga${fol.follows.length === 1 ? '' : 's'} · ${status}
|
||
<button class="btn small" id="checknow" ${fol.checking ? 'disabled' : ''}>${icon('refresh')} Comprobar ahora</button></span></div>
|
||
${fresh.length ? `<div class="continue">${fresh.map(f => `<a class="cont" href="#/manga/${f.manga_id}">
|
||
<img src="${esc(f.cover || '/icon.svg')}" alt="" loading="lazy">
|
||
<div class="info"><b>${esc(f.title)}</b>
|
||
<div><span class="badge">${f.newCount} ${f.newCount === 1 ? 'cap. nuevo' : 'caps. nuevos'}</span></div>
|
||
<div class="muted" style="font-size:.8rem">${f.last_new_at ? timeAgo(f.last_new_at) : ''}</div></div></a>`).join('')}</div>`
|
||
: '<p class="muted" style="margin-top:0">No hay capítulos nuevos en los mangas que sigues.</p>'}`
|
||
}
|
||
function bindNews() {
|
||
$('#checknow')?.addEventListener('click', async e => {
|
||
e.target.closest('button').disabled = true
|
||
await api('/api/follows/check', { method: 'POST' })
|
||
toast('Comprobando capítulos nuevos…')
|
||
// Refrescar la sección mientras dura la comprobación.
|
||
const poll = async () => {
|
||
const fol = await api('/api/follows').catch(() => null)
|
||
if (!fol || !$('#news')) return
|
||
$('#news').innerHTML = newsSection(fol)
|
||
bindNews()
|
||
if (fol.checking) setTimeout(poll, 3000)
|
||
else toast('Comprobación terminada')
|
||
}
|
||
setTimeout(poll, 2000)
|
||
})
|
||
}
|
||
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
|
||
// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h).
|
||
async function loadRecs(refresh = false) {
|
||
if (!$('#recs')) return
|
||
const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null)
|
||
// La portada pudo redibujarse mientras tanto: usar la sección actual.
|
||
const box = $('#recs')
|
||
if (!box) return
|
||
if (!r?.items?.length) { box.innerHTML = ''; return }
|
||
box.innerHTML = `<div class="row rhead"><h2>${icon('sparkles')} Recomendado para ti</h2>
|
||
<span class="muted small">Según lo que más lees: ${r.genres.map(g => `<a href="${tagHref(g.source || 'md', g)}">${esc(g.name)}</a>`).join(', ')}</span>
|
||
<button class="btn small" id="recref" title="Otras recomendaciones">${icon('refresh')}</button></div>
|
||
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
|
||
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) })
|
||
}
|
||
// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local.
|
||
async function viewHomeOffline(params) {
|
||
app.innerHTML = `
|
||
<section id="cont"></section>
|
||
<h2>${icon('download')} Descargado en el servidor</h2>
|
||
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
|
||
<div id="results" class="loading">Cargando…</div>`
|
||
const [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 = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||
const res = $('#results')
|
||
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||
const render = () => {
|
||
const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean)
|
||
const shown = list.filter(m => words.every(w => fold(m.title).includes(w)))
|
||
res.className = shown.length ? 'grid' : 'loading'
|
||
res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.'
|
||
}
|
||
$('form.search').addEventListener('submit', e => e.preventDefault())
|
||
$('[name=q]').addEventListener('input', render)
|
||
render()
|
||
}
|
||
|
||
// Portada sin conexión con el servidor: lo guardado en este dispositivo.
|
||
function viewHomeDevice() {
|
||
const dev = OFFLINE_OK ? offByManga() : []
|
||
app.innerHTML = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
|
||
? `<div class="grid">${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}</div>`
|
||
: `<p class="muted">${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.'
|
||
: 'Leer sin conexión necesita abrir la app por HTTPS.'}</p>`)
|
||
}
|
||
|
||
async function viewHome(params) {
|
||
if (!serverReachable) return viewHomeDevice()
|
||
if (!serverOnline) return viewHomeOffline(params)
|
||
const q = params.get('q') || ''
|
||
const tag = params.get('tag') || ''
|
||
const tagName = params.get('tagName') || tag
|
||
const anyLang = params.get('any') === '1'
|
||
const 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 = `
|
||
<section id="cont"></section>
|
||
<section id="recs"></section>
|
||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
|
||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
|
||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
|
||
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
|
||
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
|
||
<div id="results" class="loading">Cargando…</div>
|
||
<div class="row" style="justify-content:center;margin:16px 0"><button class="btn" id="more" hidden>${icon('more')} Cargar más</button></div>`
|
||
const form = $('form', app)
|
||
const go = (extra = {}) => {
|
||
const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...(tag ? { tag, tagName } : {}), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra })
|
||
location.hash = `#/?${p}`
|
||
}
|
||
form.addEventListener('submit', e => { e.preventDefault(); go() })
|
||
$('[name=any]').addEventListener('change', () => go())
|
||
app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` }))
|
||
app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${b.dataset.s}&mode=${mode}${anyLang ? '&any=1' : ''}` }))
|
||
|
||
if (!q && !tag && !showCats) {
|
||
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
|
||
if (!$('#cont')) return
|
||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||
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]) => `<h3>${esc(g)}</h3><div class="tags">${list.map(t => `<a class="tag big" href="${tagHref(source, t)}">${esc(t.name)}</a>`).join('')}</div>`).join('')
|
||
return
|
||
}
|
||
|
||
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
|
||
const pageable = source === 'md' || !!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 ? `<dt>${k}</dt><dd>${v}</dd>` : '')
|
||
const t = d.titles
|
||
const people = d.staff.reduce((m, p) => m.set(p.name, [...(m.get(p.name) || []), AL_ROLE(p.role)]), new Map())
|
||
const dates = d.start ? `${fmtFuzzy(d.start)}${d.end ? ` – ${fmtFuzzy(d.end)}` : d.status === 'RELEASING' ? ' – actualidad' : ''}` : ''
|
||
const size = [d.volumes ? `${d.volumes} tomos` : '', d.chapters ? `${d.chapters} capítulos` : d.latestChapter ? `${d.latestChapter} capítulos (hasta ahora)` : ''].filter(Boolean).join(' · ')
|
||
const ranks = d.rankings.map(r => `#${r.rank} ${r.type === 'RATED' ? 'mejor valorado' : 'más popular'}${r.allTime ? ' de siempre' : ` de ${r.year}`}`)
|
||
const cardOf = r => `<a class="card" href="#/?q=${encodeURIComponent(r.title)}" title="Buscar «${esc(r.title)}»">
|
||
<img class="cover" loading="lazy" src="${esc(r.cover || '/icon.svg')}" alt="" data-fallback>
|
||
<div class="t">${esc(r.title)}</div><div class="sub">${esc([r.relation ? AL_REL[r.relation] || r.relation : '', r.year || '', r.score ? `${r.score}%` : ''].filter(Boolean).join(' · '))}</div></a>`
|
||
const seen = new Set([manga.title.toLowerCase()])
|
||
const altTitles = [t.english, t.romaji, t.native].filter(x => x && !seen.has(x.toLowerCase()) && seen.add(x.toLowerCase()))
|
||
const srcDesc = (manga.description || '').trim()
|
||
return {
|
||
banner: d.banner,
|
||
sub: altTitles.join(' · '),
|
||
scores: `<div class="scores">
|
||
${d.score != null ? `<div class="score"><b>${d.score}%</b><span>AniList</span></div>` : ''}
|
||
${d.muScore != null ? `<div class="score"><b>${Number(d.muScore).toFixed(1)}</b><span>MangaUpdates${d.muVotes ? ` · ${fmtNum(d.muVotes)} votos` : ''}</span></div>` : ''}
|
||
${d.popularity ? `<div class="score"><b>${fmtNum(d.popularity)}</b><span>lectores en AniList</span></div>` : ''}
|
||
</div>
|
||
${ranks.length ? `<div class="tags">${ranks.map(r => `<span class="tag hot">${esc(r)}</span>`).join('')}</div>` : ''}`,
|
||
kv: `${people.size ? [...people].map(([name, roles]) => row(esc(roles.join(' y ')), esc(name))).join('') : row('Autor', esc(manga.authors.join(', ')))}
|
||
${row('Revista', d.magazines.map(m => esc(m.name) + (m.publisher ? ` <span class="muted">(${esc(m.publisher)})</span>` : '')).join(', '))}
|
||
${row('Editorial', d.publishers.filter(p => p.type === 'Original').map(p => esc(p.name)).join(', '))}
|
||
${row('En inglés', d.publishers.filter(p => p.type === 'English').map(p => esc(p.name)).join(', '))}
|
||
${row('Tipo', esc([AL_FORMAT[d.format] || d.format, AL_COUNTRY[d.country]].filter(Boolean).join(' · ')))}
|
||
${row('Publicación', esc(dates || manga.year || ''))}
|
||
${row('Estado', esc([AL_STATUS[d.status], d.statusDetail].filter(Boolean).join(' · ') || manga.status || ''))}
|
||
${row('Extensión', esc(size))}
|
||
${row('Anime', [...d.adaptations.map(a => esc(`${AL_ANIME[a.format] || a.format || 'Anime'}${a.year ? ` (${a.year})` : ''}`)), d.anime ? `<span class="muted">adapta: ${esc(d.anime.start)}${d.anime.end ? ` → ${esc(d.anime.end)}` : ''}</span>` : ''].filter(Boolean).join('<br>'))}`,
|
||
genres: d.genres.map(g => `<span class="tag">${esc(g)}</span>`).join(''),
|
||
softTags: d.tags.map(g => `<span class="tag soft">${esc(g)}</span>`).join(''),
|
||
synopsis: `${srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : ''}
|
||
${d.description ? (srcDesc ? `<details class="more"><summary>Sinopsis de AniList (en inglés)</summary><p class="desc long">${esc(d.description)}</p></details>` : `<h2>Sinopsis <span class="muted">(en inglés)</span></h2><p class="desc long">${esc(d.description)}</p>`) : ''}`,
|
||
extra: `${t.other.length ? `<h2>Otros títulos</h2><p class="muted">${t.other.map(esc).join(', ')}</p>` : ''}
|
||
${d.related.length ? `<h2>Obras relacionadas</h2><div class="hrow">${d.related.map(cardOf).join('')}</div>` : ''}
|
||
${d.recommendations.length ? `<h2>Si te gusta, prueba</h2><div class="hrow">${d.recommendations.map(cardOf).join('')}</div>` : ''}
|
||
${d.links.length ? `<h2>Enlaces</h2><div class="tags">${d.links.map(l => `<a class="tag" href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.site)}</a>`).join('')}</div>` : ''}
|
||
<p class="muted note">Datos de AniList y MangaUpdates${d.matched?.title ? ` (identificado por título como «${esc(d.matched.title)}»)` : ''}. Las fechas de los capítulos son las de la primera traducción conocida.</p>`,
|
||
}
|
||
}
|
||
|
||
async function viewManga(id, langOverride) {
|
||
if (cleanup) { cleanup(); cleanup = null }
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, 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 = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}</a>`
|
||
} else if (progress) {
|
||
mainBtn = `<a class="btn primary" href="${readUrl(id, progress.chapter_id, progress.page)}">${icon('play')} Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}</a>`
|
||
}
|
||
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||
|
||
const menuItems = [
|
||
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||
].join('')
|
||
|
||
const srcTags = manga.tags.map(t => (t.id ? `<a class="tag" href="${tagHref(manga.source, t)}">${esc(t.name)}</a>` : `<span class="tag">${esc(t.name ?? t)}</span>`)).join('')
|
||
const srcSub = `${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}`
|
||
app.innerHTML = `
|
||
<div class="banner" id="mbanner" hidden></div>
|
||
<div class="detail">
|
||
<img class="cover" id="mcover" src="${esc(manga.cover || '/icon.svg')}" alt="">
|
||
<div class="detail-body">
|
||
<h1>${esc(manga.title)}</h1>
|
||
<div class="muted" id="msub">${srcSub}</div>
|
||
<div id="mscores"></div>
|
||
<div class="actions">${mainBtn}
|
||
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
|
||
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
|
||
</div>
|
||
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
|
||
<div class="info-block" id="msyn"></div>
|
||
<div class="tags" id="mtags"></div>
|
||
</div>
|
||
</div>
|
||
<div class="info-block" id="mextra"></div>
|
||
<div class="drawer-back" id="libback" hidden></div>
|
||
<aside class="drawer" id="libdrawer" aria-label="Más opciones" inert>
|
||
<div class="drawer-head"><b>Opciones</b><button class="btn small" id="libclose" title="Cerrar" aria-label="Cerrar">${icon('x')}</button></div>
|
||
<div class="drawer-opts">
|
||
<button class="btn" id="follow"></button>
|
||
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
|
||
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
|
||
${menuItems}
|
||
</div>
|
||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
||
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
|
||
</aside>
|
||
<div class="list-head">
|
||
<h2>Capítulos <span class="muted">(${chapters.length})</span>${newSet.size ? ` <span class="badge">${newSet.size} ${newSet.size === 1 ? 'nuevo' : 'nuevos'}</span>` : ''}</h2>
|
||
<div class="row">
|
||
${isLc ? '' : `<select id="lang" title="Idioma de los capítulos">${langOpts.map(l => `<option value="${esc(l)}" ${l === lang ? 'selected' : ''}>${esc(LANGS[l] || l)}</option>`).join('')}</select>`}
|
||
${chapters.length > 1 ? `<button class="btn small" id="order">${icon('sort')} Invertir orden</button>` : ''}
|
||
</div>
|
||
</div>
|
||
${chapters.length ? '<ul class="chapters"></ul>' : isLc ? '<p class="muted">Este manga no tiene capítulos.</p>' : `<p class="muted">No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia).
|
||
Prueba a buscarlo en <a class="tag" href="${altSearch}">LeerCapitulo</a> o <a class="tag" href="${altSearchMi}">M440</a>.
|
||
${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `<a class="tag" href="#" data-lang="${esc(l)}">${esc(LANGS[l] || l)}</a>`).join(' ')}` : 'No hay capítulos en ningún idioma.'}</p>`}
|
||
<div id="selbar" hidden></div>`
|
||
|
||
// --- Información de la obra: la guardada llega con la ficha; si aún no hay, se pide y se pinta al llegar.
|
||
const synBox = $('#msyn')
|
||
const paintInfo = d => {
|
||
if (!synBox.isConnected) return // ya se cambió de página
|
||
const parts = d?.found ? infoParts(d, manga) : null
|
||
const banner = $('#mbanner')
|
||
// El de AniList (&b=1: reescalado) o, si no tiene, uno de tus recortes; sin ninguno, nada.
|
||
const bannerUrl = parts?.banner ? `${parts.banner}&b=1` : clipBanner
|
||
banner.hidden = !bannerUrl
|
||
banner.style.backgroundImage = bannerUrl ? `url("${bannerUrl}")` : ''
|
||
if (!manga.cover && d?.cover) $('#mcover').src = d.cover
|
||
$('#msub').innerHTML = parts ? esc(parts.sub) : srcSub
|
||
$('#mscores').innerHTML = parts?.scores || ''
|
||
const folder = download ? `<dt>Carpeta</dt><dd>mangas/${esc(download.folder)}</dd>` : ''
|
||
$('#mkv').innerHTML = (parts?.kv || '') + folder
|
||
$('#mdata').hidden = !parts && !folder
|
||
$('#mtags').innerHTML = (srcTags || parts?.genres || '') + (parts?.softTags || '')
|
||
const srcDesc = (manga.description || '').trim()
|
||
synBox.innerHTML = parts?.synopsis || (srcDesc ? `<h2>Sinopsis</h2><p class="desc long">${esc(srcDesc)}</p>` : '')
|
||
$('#mextra').innerHTML = parts?.extra || (d ? `<p class="muted note">No se encontró esta obra en AniList ni en MangaUpdates. Puede tener otro título allí: prueba a buscarla en
|
||
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
|
||
}
|
||
paintInfo(savedInfo)
|
||
if (!savedInfo) {
|
||
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
|
||
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
|
||
}
|
||
|
||
// --- Menú lateral (botón ⋯): seguir, listas, recortes, otras fuentes, copia local, reescalado HD, este dispositivo y datos de la obra.
|
||
const openLib = open => {
|
||
$('#libdrawer').classList.toggle('open', open)
|
||
$('#libdrawer').inert = !open
|
||
$('#libback').hidden = !open
|
||
}
|
||
// El ⋯ avisa con un punto si hay algo en curso (descarga, reescalado o guardado en el dispositivo).
|
||
const libBusy = {}
|
||
const markLib = (kind, busy) => {
|
||
libBusy[kind] = busy
|
||
$('#libbtn').innerHTML = `${icon('dots')}${Object.values(libBusy).some(Boolean) ? ' <span class="libbusy" title="En curso"></span>' : ''}`
|
||
}
|
||
const toChapters = () => $('.list-head')?.scrollIntoView({ behavior: 'smooth' })
|
||
$('#libbtn').addEventListener('click', () => openLib(true))
|
||
$('#libclose').addEventListener('click', () => openLib(false))
|
||
$('#libback').addEventListener('click', () => openLib(false))
|
||
$('#tochap')?.addEventListener('click', toChapters)
|
||
const onLibKey = e => { if (e.key === 'Escape') openLib(false) }
|
||
document.addEventListener('keydown', onLibKey)
|
||
|
||
const secHead = (ic, title, status) => `<div class="psec-head"><div class="psec-title">${icon(ic)} ${title}</div>${status ? `<div class="psec-status muted">${status}</div>` : ''}</div>`
|
||
|
||
// --- Selector de capítulos compartido por "Copia local" (kind 'dl') y "Reescalado HD" (kind 'hd'):
|
||
// todo lo pendiente (solo HD), un rango o una selección hecha en la lista de abajo.
|
||
let wantedSet = new Set(download?.wanted || [])
|
||
let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
|
||
let hdInfo = upscale
|
||
let sel = null // null | { kind: 'dl' | 'hd', mode: 'pending' | 'range' | 'pick' }
|
||
const picked = new Set()
|
||
let lastPick = null
|
||
const KINDS = {
|
||
dl: {
|
||
verb: 'Descargar', ic: 'download', modes: { range: 'Rango', pick: 'Elegir' },
|
||
// Sin descargar y sin estar ya en cola.
|
||
candidates: () => chapters.filter(c => !dlSet.has(c.id) && !wantedSet.has(c.id)),
|
||
rangeBase: () => chapters,
|
||
mark: c => (dlSet.has(c.id) ? ' (descargado)' : ''),
|
||
summary: ids => {
|
||
const pages = ids.reduce((n, cid) => n + (chapters.find(c => c.id === cid)?.pages || 0), 0)
|
||
return plural(ids.length, 'capítulo') + (pages ? ` · ${pages} págs.` : '')
|
||
},
|
||
offTitle: c => (dlSet.has(c.id) ? 'Ya descargado' : 'Ya está en cola'),
|
||
run: async ids => {
|
||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, chapterIds: ids } }))
|
||
toast(`${plural(ids.length, 'capítulo')} en cola de descarga`)
|
||
},
|
||
},
|
||
hd: {
|
||
verb: 'Reescalar', ic: 'sparkles', modes: { pending: 'Pendientes', range: 'Rango', pick: 'Elegir' },
|
||
// Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar).
|
||
candidates: () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error')),
|
||
rangeBase: () => chapters.filter(c => dlSet.has(c.id)),
|
||
mark: c => (hdSet.has(c.id) ? ' (HD)' : ''),
|
||
summary: ids => {
|
||
const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
|
||
return `${plural(ids.length, 'capítulo')} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
|
||
},
|
||
offTitle: c => (!dlSet.has(c.id) ? 'No está descargado' : 'Ya está en HD o en cola'),
|
||
run: async ids => {
|
||
renderHd(await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } }))
|
||
toast(`${plural(ids.length, 'capítulo')} en cola para HD`)
|
||
},
|
||
},
|
||
}
|
||
if (OFFLINE_OK) KINDS.off = {
|
||
verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' },
|
||
candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id),
|
||
rangeBase: () => chapters,
|
||
mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''),
|
||
summary: ids => plural(ids.length, 'capítulo'),
|
||
offTitle: () => 'Ya está en el dispositivo',
|
||
run: async ids => {
|
||
offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } }))
|
||
toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`)
|
||
},
|
||
}
|
||
const selIds = () => {
|
||
if (!sel) return []
|
||
const K = KINDS[sel.kind]
|
||
const cand = K.candidates()
|
||
if (sel.mode === 'pending') return cand.map(c => c.id)
|
||
if (sel.mode === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
|
||
const base = K.rangeBase()
|
||
let a = base.findIndex(c => c.id === $('#selfrom')?.value), b = base.findIndex(c => c.id === $('#selto')?.value)
|
||
if (a < 0 || b < 0) return []
|
||
if (a > b) [a, b] = [b, a]
|
||
const inRange = new Set(base.slice(a, b + 1).map(c => c.id))
|
||
return cand.filter(c => inRange.has(c.id)).map(c => c.id)
|
||
}
|
||
const updateSummary = () => {
|
||
const ids = selIds()
|
||
const K = sel && KINDS[sel.kind]
|
||
if ($('#selsum')) $('#selsum').textContent = ids.length ? K.summary(ids) : sel?.mode === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
|
||
if ($('#selgo')) $('#selgo').disabled = !ids.length
|
||
const bar = $('#selbar')
|
||
if (!bar) return
|
||
bar.hidden = sel?.mode !== 'pick'
|
||
if (sel?.mode !== 'pick') return
|
||
bar.innerHTML = `<span>${ids.length ? K.summary(ids) : 'Ningún capítulo marcado'}</span>
|
||
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon(K.ic)} ${K.verb}</button>
|
||
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
|
||
$('#selrun').addEventListener('click', runSel)
|
||
$('#selexit').addEventListener('click', () => setSel(null))
|
||
}
|
||
async function runSel() {
|
||
const ids = selIds()
|
||
if (!ids.length) return
|
||
const K = KINDS[sel.kind]
|
||
try {
|
||
if (sel.kind === 'hd' && sel.mode === 'pending' && $('#hdautoform') && $('#hdautoform').checked !== !!hdInfo?.auto) {
|
||
await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
|
||
}
|
||
picked.clear()
|
||
setSel(null)
|
||
await K.run(ids)
|
||
} catch (err) { toast(err.message) }
|
||
}
|
||
function setSel(next) {
|
||
const wasPick = sel?.mode === 'pick', prevKind = sel?.kind
|
||
if (next?.kind !== prevKind) picked.clear()
|
||
sel = next
|
||
if (prevKind && prevKind !== next?.kind) renderSelForm(prevKind) // cerrar el otro formulario
|
||
if (next) renderSelForm(next.kind)
|
||
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
||
if (next?.mode === 'pick' && !wasPick) { openLib(false); toChapters() } // se marca en la lista: hay que verla
|
||
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
||
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
||
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
|
||
updateSummary()
|
||
}
|
||
function renderSelForm(kind) {
|
||
const box = $(`#${kind}form`)
|
||
if (!box) return
|
||
const open = sel?.kind === kind
|
||
box.hidden = !open
|
||
if (!open) { box.innerHTML = ''; return }
|
||
const K = KINDS[kind]
|
||
const base = K.rangeBase(), cand = K.candidates()
|
||
const opt = (c, on) => `<option value="${c.id}" ${on ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${K.mark(c)}</option>`
|
||
const from = cand[0] || base[0], to = cand[Math.min(cand.length, 10) - 1] || base.at(-1)
|
||
box.innerHTML = `
|
||
<div class="seg" role="tablist">${Object.entries(K.modes).map(([m, label]) => `<button data-sm="${m}" class="${sel.mode === m ? 'on' : ''}">${label}</button>`).join('')}</div>
|
||
${sel.mode === 'range' ? `<div class="row range"><label>Desde <select id="selfrom">${base.map(c => opt(c, c === from)).join('')}</select></label>
|
||
<label>hasta <select id="selto">${base.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
|
||
${sel.mode === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
|
||
${sel.mode === 'pending' ? `<div class="note muted">Todos los capítulos descargados que aún no están en HD.</div>
|
||
<label class="check"><input type="checkbox" id="hdautoform" ${hdInfo?.auto ? 'checked' : ''}> Reescalar también los que se descarguen en el futuro</label>` : ''}
|
||
<div class="row"><b id="selsum" class="sum"></b></div>
|
||
<div class="psec-actions"><button class="btn small primary" id="selgo">${icon(K.ic)} ${K.verb}</button><button class="btn small" id="selclose">Cerrar</button></div>`
|
||
box.querySelectorAll('[data-sm]').forEach(b => b.addEventListener('click', () => setSel({ kind, mode: b.dataset.sm })))
|
||
$('#selfrom')?.addEventListener('change', updateSummary)
|
||
$('#selto')?.addEventListener('change', updateSummary)
|
||
$('#selclose').addEventListener('click', () => setSel(null))
|
||
$('#selgo').addEventListener('click', runSel)
|
||
}
|
||
// Tras un sondeo: repintar lo que dependa de los conjuntos y cerrar el selector si ya no queda nada.
|
||
const refreshSel = () => {
|
||
renderList()
|
||
if (sel && sel.mode !== 'pick') renderSelForm(sel.kind)
|
||
if (sel && !KINDS[sel.kind].candidates().length) setSel(null)
|
||
else updateSummary()
|
||
}
|
||
|
||
// --- Copia local (descarga) con sondeo del progreso mientras está activa.
|
||
let dlTimer = null
|
||
const renderDl = d => {
|
||
const box = $('#dl')
|
||
if (!box) return
|
||
const active = d && DL_ACTIVE.includes(d.status)
|
||
markLib('dl', !!active)
|
||
const some = d?.scope === 'some'
|
||
const partial = d && d.done < d.total
|
||
const bar = d && (active || partial) ? `<div class="bar dlbar"><i style="width:${downloadPct(d).toFixed(1)}%"></i></div>` : ''
|
||
const canPick = !offline && chapters.some(c => !dlSet.has(c.id) && !wantedSet.has(c.id) && !(d?.wanted || []).includes(c.id))
|
||
const pickBtn = label => (canPick ? `<button class="btn small ${sel?.kind === 'dl' ? 'on' : ''}" id="dlopen">${icon('download')} ${label}</button>` : '')
|
||
const delBtn = d ? `<button class="btn small danger push" id="dldel">${icon('trash')} Eliminar copia${d.size ? ` · ${fmtSize(d.size)}` : ''}</button>` : ''
|
||
let status = '', body = '', actions = ''
|
||
if (!d) {
|
||
body = '<div class="note muted">Guarda capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD). Puedes bajar el manga entero o solo algunos capítulos.</div>'
|
||
actions = `<button class="btn small" id="dlall">${icon('download')} Descargar todo</button>${pickBtn('Elegir capítulos…')}`
|
||
} else if (active) {
|
||
status = `${downloadLabel(d)} · ${Math.floor(downloadPct(d))}%`
|
||
body = `${bar}<div class="note muted">${downloadDetail(d)}</div>`
|
||
actions = `${pickBtn('Añadir capítulos…')}<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
|
||
} else {
|
||
const have = dlSet.size
|
||
status = d.status === 'done'
|
||
? `${icon('check')} ${some ? `${have} de ${chapters.length}` : have} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}`
|
||
: downloadLabel(d)
|
||
body = bar + (d.status === 'error' && d.error ? `<div class="error note">${esc(d.error)}</div>` : '')
|
||
+ (some && d.status === 'done' ? '<div class="note muted">Solo capítulos elegidos: los nuevos no se descargan solos.</div>' : '')
|
||
const resume = d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : null
|
||
actions = some
|
||
? `${resume ? `<button class="btn small" id="dlgo">${icon('refresh')} ${resume}</button>` : ''}${pickBtn('Descargar más…')}<button class="btn small" id="dlall" title="Bajar también todos los capítulos que falten y, en adelante, los nuevos">${icon('download')} Descargar todo</button>${delBtn}`
|
||
: `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(resume ? 'download' : 'refresh')} ${resume || 'Buscar capítulos nuevos'}</button>${delBtn}`
|
||
}
|
||
if (!$('#dltop')) box.innerHTML = '<div id="dltop" class="psec-grid"></div><div id="dlform" class="hdform" hidden></div>'
|
||
$('#dltop').innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
|
||
$('#dlgo')?.addEventListener('click', async () => {
|
||
// Reanudar respeta el modo (todo o capítulos elegidos).
|
||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title, ...(some ? { chapterIds: d.wanted?.length ? d.wanted : [] } : {}) } }).catch(err => (toast(err.message), d)))
|
||
})
|
||
$('#dlall')?.addEventListener('click', async () => {
|
||
const missing = chapters.filter(c => !dlSet.has(c.id)).length
|
||
if (d && !confirm(`¿Descargar todo «${manga.title}»?\nSe bajarán ${plural(missing, 'capítulo')} que faltan y, en adelante, los nuevos.`)) return
|
||
renderDl(await api(`/api/download/${id}`, { method: 'POST', body: { lang, title: manga.title } }))
|
||
toast(`Descargando en mangas/${manga.title}`)
|
||
})
|
||
$('#dlopen')?.addEventListener('click', () => setSel(sel?.kind === 'dl' ? null : { kind: 'dl', mode: 'range' }))
|
||
$('#dlcancel')?.addEventListener('click', async () => renderDl(await api(`/api/download/${id}/cancel`, { method: 'POST' })))
|
||
$('#dldel')?.addEventListener('click', async () => {
|
||
if (!confirm(d.shared ? `¿Quitar «${manga.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan para él y lo puedes seguir leyendo.`
|
||
: `¿Eliminar la copia local de «${manga.title}»?\nSe borrará la carpeta mangas/${d.folder}${d.size ? ` (${fmtSize(d.size)})` : ''}, incluida la versión HD.\nTu historial de lectura no se toca.`)) return
|
||
const r = await api(`/api/download/${id}`, { method: 'DELETE' })
|
||
toast(r.kept ? 'Quitado de tu biblioteca' : 'Copia local eliminada')
|
||
viewManga(id, langOverride)
|
||
})
|
||
const wantedBefore = wantedSet.size
|
||
wantedSet = new Set(d?.wanted || [])
|
||
if (wantedSet.size !== wantedBefore) refreshSel()
|
||
clearTimeout(dlTimer)
|
||
if (active) dlTimer = setTimeout(async () => {
|
||
const next = await api(`/api/download/${id}`).catch(() => d)
|
||
if (!$('#dl')) return
|
||
if (next && next.done !== d.done) {
|
||
const fresh = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`).catch(() => null)
|
||
fresh?.downloaded.forEach(x => dlSet.add(x))
|
||
if (fresh?.download) next.size = fresh.download.size
|
||
if (fresh?.upscale) renderHd(fresh.upscale)
|
||
refreshSel()
|
||
}
|
||
renderDl(next)
|
||
}, 2000)
|
||
}
|
||
|
||
// --- Reescalado HD de lo descargado.
|
||
let hdTimer = null
|
||
const renderHd = u => {
|
||
const box = $('#hd')
|
||
if (!box) return
|
||
clearTimeout(hdTimer)
|
||
hdInfo = u
|
||
const show = !!(u?.available && u.downloaded && !offline)
|
||
box.hidden = !show
|
||
markLib('hd', show && (!!u.current || Object.values(u.jobs).some(s => s !== 'error')))
|
||
if (show) {
|
||
const queued = Object.values(u.jobs).filter(s => s !== 'error').length
|
||
const active = !!u.current || queued > 0
|
||
const c = u.current
|
||
const status = `${u.hd} de ${u.downloaded} capítulos${u.size ? ` · ${fmtSize(u.size)}` : ''}`
|
||
const remaining = Object.entries(u.jobs).filter(([, st]) => st !== 'error').map(([cid]) => cid)
|
||
let body = ''
|
||
if (active) {
|
||
const pct = c?.total ? (c.pages / c.total) * 100 : 0
|
||
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||
<div class="note muted">${c ? `Cap. ${esc(c.num ?? '?')} · pág. ${c.pages}/${c.total}` : 'Esperando turno…'}${queued > 1 ? ` · ${queued - 1} más en cola` : ''} · faltan ${fmtDur(remaining.reduce((n, cid) => n + (u.pages?.[cid] || 0), 0) * u.secPerPage - (c?.pages || 0) * u.secPerPage)}</div>`
|
||
} else if (!u.hd) {
|
||
body = `<div class="note muted">Mejora la nitidez con IA (Real-ESRGAN ×2). Unos ${u.secPerPage} s por página; el original no se toca.</div>`
|
||
}
|
||
if (u.error) body += `<div class="error note">Falló algún capítulo: ${esc(u.error)}</div>`
|
||
const canAdd = KINDS.hd.candidates().length > 0
|
||
const actions = [
|
||
canAdd ? `<button class="btn small ${sel?.kind === 'hd' ? 'on' : ''}" id="hdopen">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
|
||
active ? `<button class="btn small" id="hdcancel">${icon('stop')} Detener</button>` : '',
|
||
u.hd ? `<button class="btn small danger push" id="hddel">${icon('trash')} Quitar HD</button>` : '',
|
||
].join('')
|
||
// Los formularios se conservan entre sondeos: solo se repinta la parte de arriba.
|
||
if (!$('#hdtop')) box.innerHTML = '<div id="hdtop" class="psec-grid"></div><div id="hdform" class="hdform" hidden></div>'
|
||
$('#hdtop').innerHTML = secHead('sparkles', 'Reescalado HD', status) + body + (actions ? `<div class="psec-actions">${actions}</div>` : '')
|
||
+ (u.auto ? '<div class="note muted">Los capítulos que se descarguen también se reescalan.</div>' : '')
|
||
$('#hdopen')?.addEventListener('click', () => setSel(sel?.kind === 'hd' ? null : { kind: 'hd', mode: 'pending' }))
|
||
$('#hdcancel')?.addEventListener('click', async () => {
|
||
if (!confirm('¿Detener el reescalado?\nSe vacía la cola de este manga; lo ya reescalado se queda.')) return
|
||
renderHd(await api(`/api/upscale/${id}/cancel`, { method: 'POST' }))
|
||
})
|
||
$('#hddel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Quitar la versión HD de «${manga.title}»?\nSe borra mangas/${download?.folder ?? ''}/.hd${u.size ? ` (${fmtSize(u.size)})` : ''}; los capítulos originales se quedan.`)) return
|
||
renderHd(await api(`/api/upscale/${id}`, { method: 'DELETE' }))
|
||
toast('Versión HD eliminada')
|
||
})
|
||
if (active) hdTimer = setTimeout(async () => { const next = await api(`/api/upscale/${id}`).catch(() => null); if (next && $('#hd')) renderHd(next) }, 3000)
|
||
}
|
||
const changed = (u?.chapters?.length ?? 0) !== hdSet.size || JSON.stringify(u?.jobs || {}) !== JSON.stringify(hdJobs)
|
||
hdSet = new Set(u?.chapters || []); hdJobs = u?.jobs || {}
|
||
if (changed) refreshSel()
|
||
}
|
||
|
||
// --- En este dispositivo (sin conexión).
|
||
const renderOff = () => {
|
||
const box = $('#off')
|
||
if (!box || !OFFLINE_OK) return
|
||
const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id)
|
||
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
|
||
const run = offState.running?.mangaId === id ? offState.running : null
|
||
const queued = offState.queue.filter(q => q.mangaId === id).length
|
||
markLib('off', !!(run || queued))
|
||
box.hidden = false
|
||
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
|
||
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
|
||
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
|
||
let body = ''
|
||
if (run || queued) {
|
||
const pct = run?.total ? (run.done / run.total) * 100 : 0
|
||
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
|
||
<div class="note muted">${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}</div>`
|
||
} else if (!mine.length) body = '<div class="note muted">Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).</div>'
|
||
const actions = [
|
||
nextIds.length && !run ? `<button class="btn small" id="offnext">${icon('phone')} Guardar ${nextIds.length === 1 ? 'el siguiente' : `los ${nextIds.length} siguientes`}</button>` : '',
|
||
KINDS.off.candidates().length ? `<button class="btn small ${sel?.kind === 'off' ? 'on' : ''}" id="offopen">${icon('phone')} Elegir capítulos…</button>` : '',
|
||
mine.length ? `<button class="btn small danger push" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : '',
|
||
].join('')
|
||
if (!$('#offtop')) box.innerHTML = '<div id="offtop" class="psec-grid"></div><div id="offform" class="hdform" hidden></div>'
|
||
$('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `<div class="psec-actions">${actions}</div>`
|
||
$('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id)))
|
||
$('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' }))
|
||
$('#offdel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return
|
||
await offRemove(mine.map(([cid]) => cid))
|
||
toast('Borrado del dispositivo')
|
||
})
|
||
}
|
||
let offRefreshAt = 0
|
||
const onOff = () => {
|
||
if (!$('#off')) return
|
||
renderOff()
|
||
// La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada).
|
||
const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0)
|
||
if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() }
|
||
}
|
||
if (OFFLINE_OK) offState.listeners.add(onOff)
|
||
|
||
// --- Listas propias (menú "Listas").
|
||
let listSet = new Set(inLists)
|
||
const renderListsBtn = () => {
|
||
const b = $('#lbtn')
|
||
if (!b) return
|
||
b.classList.toggle('on', listSet.size > 0)
|
||
b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}`
|
||
}
|
||
renderListsBtn()
|
||
async function renderListsMenu() {
|
||
const box = $('#lmlist')
|
||
if (!box) return
|
||
const lists = await api('/api/lists').catch(() => [])
|
||
box.innerHTML = lists.map(l => `<label class="check lcheck"><input type="checkbox" data-list="${l.id}" ${listSet.has(l.id) ? 'checked' : ''}> ${esc(l.name)}</label>`).join('')
|
||
+ `<button id="lnew">${icon('plus')} Nueva lista…</button><a href="#/library">${icon('library')} Ver mi librería</a>`
|
||
box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => {
|
||
const lid = Number(x.dataset.list)
|
||
try {
|
||
const r = x.checked
|
||
? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
|
||
: await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||
listSet = new Set(r.lists)
|
||
renderListsBtn()
|
||
} catch (err) { toast(err.message); x.checked = !x.checked }
|
||
}))
|
||
$('#lnew').addEventListener('click', async () => {
|
||
const name = prompt('Nombre de la nueva lista:')
|
||
if (!name?.trim()) return
|
||
try {
|
||
const l = await api('/api/lists', { method: 'POST', body: { name } })
|
||
const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
|
||
listSet = new Set(r.lists)
|
||
renderListsBtn()
|
||
renderListsMenu()
|
||
toast(`Añadido a «${l.name}»`)
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
}
|
||
$('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() })
|
||
|
||
const renderFollow = () => {
|
||
const b = $('#follow')
|
||
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
||
b.classList.toggle('on', isFollowing)
|
||
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
|
||
b.hidden = offline
|
||
}
|
||
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)) ? '<span class="badge">NUEVO</span> ' : '')
|
||
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : wantedSet.has(c.id) ? `<span class="queued" title="En cola de descarga">${icon('download')}…</span> ` : '')
|
||
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
|
||
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
|
||
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
|
||
+ [r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : manga.source === 'md' ? esc(c.group) : '', chapDate(c)].filter(Boolean).join(' · ')
|
||
// Fecha de cada capítulo: la primera publicación conocida (MangaDex/MangaUpdates, se pide aparte) o, si
|
||
// no, la de la fuente, salvo que sea la de una subida masiva (la misma fecha en muchos capítulos).
|
||
let extDates = {}
|
||
const srcDatesOk = (() => {
|
||
const ds = chapters.map(c => c.publishAt?.slice(0, 10)).filter(Boolean)
|
||
const n = {}
|
||
for (const d of ds) n[d] = (n[d] || 0) + 1
|
||
return ds.length < 8 || Math.max(...Object.values(n)) / ds.length <= 0.25
|
||
})()
|
||
// Fechas por capítulo: { d, approx } por id. Un capítulo no puede ser posterior al siguiente (las
|
||
// traducciones a veces llegan desordenadas) y los huecos entre dos fechas conocidas se estiman.
|
||
let dateMap = new Map()
|
||
function buildDates() {
|
||
const DAY = 864e5
|
||
const known = chapters.map(c => {
|
||
const d = extDates[numKey(c.num)] || (srcDatesOk ? c.publishAt?.slice(0, 10) : null)
|
||
const t = d ? Date.parse(`${d}T12:00:00Z`) : NaN
|
||
return { c, n: parseFloat(c.num), t: Number.isNaN(t) ? null : t }
|
||
}).filter(x => Number.isFinite(x.n)).sort((a, b) => a.n - b.n)
|
||
let min = Infinity
|
||
for (let i = known.length - 1; i >= 0; i--) if (known[i].t != null) { min = Math.min(min, known[i].t); known[i].t = min }
|
||
dateMap = new Map()
|
||
let prev = null
|
||
known.forEach((x, i) => {
|
||
if (x.t == null) return
|
||
dateMap.set(x.c.id, { t: x.t, approx: false })
|
||
if (prev && i - prev.i > 1 && i - prev.i <= 150) {
|
||
for (let k = prev.i + 1; k < i; k++) {
|
||
const f = (known[k].n - prev.n) / (x.n - prev.n)
|
||
dateMap.set(known[k].c.id, { t: Math.round((prev.t + f * (x.t - prev.t)) / DAY) * DAY, approx: true })
|
||
}
|
||
}
|
||
prev = { i, n: x.n, t: x.t }
|
||
})
|
||
}
|
||
buildDates()
|
||
const chapDate = c => {
|
||
const e = dateMap.get(c.id)
|
||
if (!e) return ''
|
||
const d = new Date(e.t).toISOString().slice(0, 10)
|
||
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
|
||
}
|
||
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 `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : KINDS[sel.kind].offTitle(c)}">
|
||
<input type="checkbox" data-i="${i}" data-cid="${c.id}" ${ok ? '' : 'disabled'} ${picked.has(c.id) ? 'checked' : ''}>
|
||
<span class="t">${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></label></li>`
|
||
}
|
||
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
|
||
}).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 = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||
const lang = store.get(`lang:${mangaId}`, me.lang)
|
||
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
|
||
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) }
|
||
const [info, chap] = await Promise.all([
|
||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
|
||
])
|
||
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 => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||
app.innerHTML = `
|
||
<div class="reader">
|
||
<div class="rbar top">
|
||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||
<button class="btn small" id="modequick"></button>
|
||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||
</div>
|
||
<div class="rpanel" hidden>
|
||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||
</div>
|
||
<div id="pages"></div>
|
||
<div class="rbar bottom">
|
||
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||
<span id="pnum" class="muted"></span>
|
||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||
${next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||
</div>
|
||
</div>`
|
||
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 = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||
|
||
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
|
||
const groupOf = i => (isDouble() ? spreadOf(i) : [i])
|
||
// Las páginas que quedan frente a frente en el libro abierto: la portada sola, luego de dos en dos; una página
|
||
// ancha ocupa las dos caras.
|
||
function spreadOf(i) {
|
||
for (let s = 0; s < total;) {
|
||
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 = () => {
|
||
if (!isWide(im) || wide.has(i)) return
|
||
wide.add(i)
|
||
refreshGutters() // una página ancha cambia las parejas de las páginas de delante
|
||
}
|
||
im.src = pages[i]
|
||
im.decode?.().catch(() => {})
|
||
preloaded.set(i, im)
|
||
}
|
||
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
|
||
scheduleLeafTextures()
|
||
}
|
||
const imgReady = im => im.complete && im.naturalWidth > 0
|
||
// Lado de la hendidura del lomo: l o r (borde de la página); una página ancha no lleva (none).
|
||
// En la vista de una página, la cara de delante de cada pareja lo tiene al lado contrario que la hoja que gira.
|
||
const gutterOf = (i, n) => {
|
||
if (wide.has(i)) return 'none'
|
||
const rtl = settings.dir === 'rtl'
|
||
if (shown.length === 2) return rtl === (n === 0) ? 'l' : 'r'
|
||
const spread = spreadOf(i)
|
||
return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l'
|
||
}
|
||
const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
|
||
// Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista.
|
||
function isPan(old) {
|
||
const imgs = old.querySelectorAll('img')
|
||
if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false
|
||
const from = Number(imgs[0].dataset.i)
|
||
return from !== shown[0] && spreadOf(from).includes(shown[0])
|
||
}
|
||
// Hacia una página que aún no ha llegado, la actual se queda (con un indicador tras un momento) hasta que
|
||
// llegue o pasen 4 s, en vez de pasar la hoja y dejar negro debajo. Si se pide otra página mientras,
|
||
// manda la última.
|
||
let showSeq = 0, waitTimer = 0
|
||
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
|
||
function showPaged(dir = 0) {
|
||
const seq = ++showSeq
|
||
clearTimeout(waitTimer)
|
||
box.classList.remove('waiting')
|
||
finishTurn()
|
||
const stage = document.createElement('div')
|
||
stage.className = 'stage'
|
||
stage.classList.toggle('two', shown.length === 2)
|
||
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||
const imgs = [...stage.querySelectorAll('img')]
|
||
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
|
||
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
|
||
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 && !isPan(old)) 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)) img.dataset.gutter = 'none'
|
||
if (isWide(img) && !wide.has(i)) {
|
||
wide.add(i)
|
||
if (shown.length === 2 && shown.includes(i)) goTo(shown[0], false, true) // reagrupar
|
||
}
|
||
}, { once: true }))
|
||
resetZoom()
|
||
window.scrollTo(0, 0)
|
||
preload()
|
||
}
|
||
// Giro entre dos parejas, en la vista de una página: la hoja lleva la página vieja por una cara y la nueva por la otra.
|
||
function isBookTurn(old, dir) {
|
||
const imgs = old.querySelectorAll('img')
|
||
if (settings.anim !== 'flip' || isDouble() || box.classList.contains('fit-width') || imgs.length !== 1 || shown.length !== 1) return false
|
||
const from = Number(imgs[0].dataset.i), to = shown[0]
|
||
if (Math.abs(from - to) !== 1) return false
|
||
const [opening, turned] = dir > 0 ? [to, from] : [from, to]
|
||
return spreadOf(opening)[0] === opening && spreadOf(opening).length === 2 && !wide.has(turned)
|
||
}
|
||
// --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
|
||
// al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
|
||
let turning = null // { anims, cleanup } (y move/release si sigue al dedo)
|
||
let wantDrag = false
|
||
let grabY = null // altura de la pantalla por la que el dedo tocó o agarró la página
|
||
// Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página).
|
||
const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12)
|
||
function finishTurn() {
|
||
if (!turning) return
|
||
const t = turning
|
||
turning = null
|
||
t.anims.forEach(a => { try { a.cancel() } catch {} })
|
||
t.cleanup()
|
||
}
|
||
// Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API
|
||
// que usa finishTurn: cancel() y la promesa finished.
|
||
function frameAnim(duration, step) {
|
||
let raf = 0, settle
|
||
const finished = new Promise((res, rej) => { settle = { res, rej } })
|
||
const t0 = performance.now()
|
||
const tick = now => {
|
||
const g = Math.min(1, (now - t0) / duration)
|
||
step(g)
|
||
if (g < 1) raf = requestAnimationFrame(tick)
|
||
else settle.res()
|
||
}
|
||
step(0)
|
||
raf = requestAnimationFrame(tick)
|
||
return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } }
|
||
}
|
||
// Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior,
|
||
// así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso
|
||
// (la página siguiente en doble página, o papel en blanco) por detrás.
|
||
// Ángulos de la hoja dividida en N tiras, común a la versión WebGL y a la de capas.
|
||
function curlCurve(N) {
|
||
// Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por
|
||
// delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el
|
||
// mismo gesto al revés, con el borde libre llegando primero).
|
||
function angles(g, reverse) {
|
||
const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2
|
||
const bend = Math.sin(Math.PI * g) * 125
|
||
return Array.from({ length: N }, (_, i) => {
|
||
const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45)))
|
||
return reverse ? 180 - a : a
|
||
})
|
||
}
|
||
// Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una
|
||
// sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo.
|
||
function span(reverse) {
|
||
const seen = g => angles(g, reverse).some(a => a < 88)
|
||
let g0 = 0, g1 = 1
|
||
while (g0 < 1 && !seen(g0)) g0 += 0.01
|
||
while (g1 > g0 && !seen(g1)) g1 -= 0.01
|
||
return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
|
||
}
|
||
// Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo)
|
||
// y el avance g que la pone donde está el dedo.
|
||
const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N
|
||
function solve(target, reverse) {
|
||
let lo = 0, hi = 1
|
||
for (let k = 0; k < 18; k++) {
|
||
const m = (lo + hi) / 2
|
||
if ((edge(m, reverse) > target) !== reverse) lo = m
|
||
else hi = m
|
||
}
|
||
return (lo + hi) / 2
|
||
}
|
||
return { angles, span, solve }
|
||
}
|
||
// --- Hoja con WebGL: una malla con la página como textura, en un solo canvas. Hace lo mismo que paperCurl
|
||
// (que queda para cuando no hay WebGL2) sin una capa por cara, que es lo que le pesa a la GPU.
|
||
const LEAF_COLS = 32, LEAF_ROWS = 8
|
||
const BOOK_FOLLOW_FROM = 0.25 // parte del giro antes de que la vista empiece a acompañar a la hoja
|
||
const LEAF_SHADOW_STEP = 2 // columnas de la hoja por cada tramo de sombra
|
||
const SHADOW_DARK = 0.95, SHADOW_SOFT = 0.4, SHADOW_FALL = 3 // oscuridad junto al papel, ancho del borde y caída con la altura
|
||
let leafGL = null, leafGLOff = false, leafTimer = 0
|
||
const leafTextures = new Map() // URL de la página → textura
|
||
const leafNoTexture = new Set() // las que no se pudieron subir (demasiado grandes, sin permiso…)
|
||
function initLeafGL() {
|
||
if (leafGL || leafGLOff) return leafGL
|
||
const canvas = document.createElement('canvas')
|
||
canvas.className = 'curlgl'
|
||
const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true, depth: true, powerPreference: 'high-performance' })
|
||
const shader = (type, src) => { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); return sh }
|
||
const prog = gl?.createProgram()
|
||
if (gl) {
|
||
// Misma proyección que perspective en CSS: el origen en el centro y la escala 1 / (1 - z / perspectiva).
|
||
gl.attachShader(prog, shader(gl.VERTEX_SHADER, `#version 300 es
|
||
uniform vec3 uView;
|
||
layout(location = 0) in vec4 aPos;
|
||
layout(location = 1) in vec2 aUV;
|
||
out vec2 vUV;
|
||
out float vC;
|
||
void main() {
|
||
float w = 1.0 - aPos.z / uView.z;
|
||
gl_Position = vec4(aPos.x / uView.x * 2.0 - 1.0, 1.0 - aPos.y / uView.y * 2.0, 1.05128 * w - 0.10256, w);
|
||
vUV = aUV;
|
||
vC = aPos.w;
|
||
}`))
|
||
// La cara se oscurece al inclinarse. El sesgo negativo evita que la página se vea blanda con los mipmaps.
|
||
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, `#version 300 es
|
||
precision highp float;
|
||
uniform sampler2D uFront, uBack;
|
||
uniform bool uPaper, uSpineLeft, uWide;
|
||
in vec2 vUV;
|
||
in float vC;
|
||
out vec4 color;
|
||
// La hendidura del lomo: los mismos puntos que data-gutter en style.css, para que no salte al empezar el giro.
|
||
float gutter(float d) {
|
||
if (d < 0.012) return mix(0.4, 0.6, d / 0.012);
|
||
if (d < 0.03) return mix(0.6, 0.8, (d - 0.012) / 0.018);
|
||
if (d < 0.06) return mix(0.8, 0.93, (d - 0.03) / 0.03);
|
||
if (d < 0.1) return mix(0.93, 1.0, (d - 0.06) / 0.04);
|
||
return 1.0;
|
||
}
|
||
void main() {
|
||
vec4 t;
|
||
float b;
|
||
float fromSpine = uSpineLeft ? vUV.x : 1.0 - vUV.x;
|
||
if (gl_FrontFacing) {
|
||
t = texture(uFront, vUV, -0.5);
|
||
b = (1.0 - (1.0 - vC) * 0.3) * (uWide ? 1.0 : gutter(fromSpine));
|
||
} else {
|
||
t = uPaper ? vec4(0.914, 0.898, 0.863, 1.0) : texture(uBack, vec2(1.0 - vUV.x, vUV.y), -0.5);
|
||
b = (1.0 - (1.0 + vC) * 0.3) * gutter(fromSpine);
|
||
}
|
||
color = vec4(t.rgb * b, t.a);
|
||
}`))
|
||
gl.linkProgram(prog)
|
||
}
|
||
if (!gl || !gl.getProgramParameter(prog, gl.LINK_STATUS)) { leafGLOff = true; return null }
|
||
gl.useProgram(prog)
|
||
gl.uniform1i(gl.getUniformLocation(prog, 'uFront'), 0)
|
||
gl.uniform1i(gl.getUniformLocation(prog, 'uBack'), 1)
|
||
gl.enable(gl.DEPTH_TEST)
|
||
gl.depthFunc(gl.LEQUAL)
|
||
gl.clearColor(0, 0, 0, 0)
|
||
const count = (LEAF_COLS + 1) * (LEAF_ROWS + 1)
|
||
const posBuf = gl.createBuffer(), uvBuf = gl.createBuffer(), indexBuf = gl.createBuffer()
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf)
|
||
gl.bufferData(gl.ARRAY_BUFFER, count * 16, gl.DYNAMIC_DRAW)
|
||
gl.enableVertexAttribArray(0)
|
||
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0)
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf)
|
||
gl.bufferData(gl.ARRAY_BUFFER, count * 8, gl.DYNAMIC_DRAW)
|
||
gl.enableVertexAttribArray(1)
|
||
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0)
|
||
const at = (c, f) => c * (LEAF_ROWS + 1) + f
|
||
const index = new Uint16Array(LEAF_COLS * LEAF_ROWS * 6)
|
||
let n = 0
|
||
for (let c = 0; c < LEAF_COLS; c++) for (let f = 0; f < LEAF_ROWS; f++) {
|
||
index.set([at(c, f), at(c, f + 1), at(c + 1, f), at(c + 1, f), at(c, f + 1), at(c + 1, f + 1)], n)
|
||
n += 6
|
||
}
|
||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuf)
|
||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW)
|
||
// Sombra sobre el papel: la proyección de la hoja con la luz de frente. Se pinta detrás de la hoja, sin imagen.
|
||
const shadowProg = gl.createProgram()
|
||
gl.attachShader(shadowProg, shader(gl.VERTEX_SHADER, `#version 300 es
|
||
uniform vec2 uSize;
|
||
layout(location = 0) in vec2 aPos;
|
||
layout(location = 1) in vec2 aRamp;
|
||
layout(location = 2) in float aAlpha;
|
||
out vec2 vRamp;
|
||
out float vAlpha;
|
||
void main() {
|
||
gl_Position = vec4(aPos.x / uSize.x * 2.0 - 1.0, 1.0 - aPos.y / uSize.y * 2.0, 0.95, 1.0);
|
||
vRamp = aRamp;
|
||
vAlpha = aAlpha;
|
||
}`))
|
||
gl.attachShader(shadowProg, shader(gl.FRAGMENT_SHADER, `#version 300 es
|
||
precision mediump float;
|
||
in vec2 vRamp;
|
||
in float vAlpha;
|
||
out vec4 color;
|
||
void main() {
|
||
vec2 r = vRamp * vRamp * (3.0 - 2.0 * vRamp);
|
||
color = vec4(0.0, 0.0, 0.0, vAlpha * r.x * r.y);
|
||
}`))
|
||
gl.linkProgram(shadowProg)
|
||
if (!gl.getProgramParameter(shadowProg, gl.LINK_STATUS)) { leafGLOff = true; return null }
|
||
// Cada tramo son 3×3 cuadros: el centro es la tira proyectada y el resto, su borde difuso.
|
||
const shadowSegs = LEAF_COLS / LEAF_SHADOW_STEP
|
||
const shadowVao = gl.createVertexArray(), shadowBuf = gl.createBuffer(), shadowIndex = gl.createBuffer()
|
||
gl.bindVertexArray(shadowVao)
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, shadowBuf)
|
||
gl.bufferData(gl.ARRAY_BUFFER, shadowSegs * 16 * 20, gl.DYNAMIC_DRAW)
|
||
gl.enableVertexAttribArray(0)
|
||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 20, 0)
|
||
gl.enableVertexAttribArray(1)
|
||
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 8)
|
||
gl.enableVertexAttribArray(2)
|
||
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 20, 16)
|
||
const shadowIdx = new Uint16Array(shadowSegs * 54)
|
||
let m = 0
|
||
for (let q = 0; q < shadowSegs; q++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) {
|
||
const v = (a, b) => q * 16 + a * 4 + b
|
||
shadowIdx.set([v(j, k), v(j, k + 1), v(j + 1, k), v(j + 1, k), v(j, k + 1), v(j + 1, k + 1)], m)
|
||
m += 6
|
||
}
|
||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, shadowIndex)
|
||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, shadowIdx, gl.STATIC_DRAW)
|
||
gl.bindVertexArray(null)
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf)
|
||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)
|
||
const aniso = gl.getExtension('EXT_texture_filter_anisotropic')
|
||
// Sin el contexto (iOS puede quitarlo con la app en segundo plano), el siguiente giro crea otro.
|
||
canvas.addEventListener('webglcontextlost', () => { if (leafGL?.gl === gl) { leafGL = null; leafTextures.clear() } })
|
||
leafGL = { canvas, gl, prog, posBuf, uvBuf, aniso, shadowProg, shadowVao, shadowBuf, shadowCount: m, shadowData: new Float32Array(shadowSegs * 16 * 5),
|
||
uSize: gl.getUniformLocation(shadowProg, 'uSize'), positions: new Float32Array(count * 4), uvs: new Float32Array(count * 2), indexCount: n,
|
||
uView: gl.getUniformLocation(prog, 'uView'), uPaper: gl.getUniformLocation(prog, 'uPaper'), uSpineLeft: gl.getUniformLocation(prog, 'uSpineLeft'), uWide: gl.getUniformLocation(prog, 'uWide'), maxSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) }
|
||
return leafGL
|
||
}
|
||
function leafTexture(im) {
|
||
const known = leafTextures.get(im.src)
|
||
if (known) return known
|
||
const { gl, aniso, maxSize } = leafGL
|
||
if (Math.max(im.naturalWidth, im.naturalHeight) > maxSize) throw new Error('página demasiado grande')
|
||
const tex = gl.createTexture()
|
||
gl.activeTexture(gl.TEXTURE0)
|
||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||
try {
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im)
|
||
gl.generateMipmap(gl.TEXTURE_2D)
|
||
if (gl.getError() !== gl.NO_ERROR) throw new Error('sin memoria para la textura')
|
||
} catch (e) { gl.deleteTexture(tex); throw e }
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))
|
||
leafTextures.set(im.src, tex)
|
||
return tex
|
||
}
|
||
// Sube con antelación, en reposo y de una en una, las páginas de los giros posibles: subirla al empezar el
|
||
// giro daría un tirón. Una página que no llegó a subirse se sube al girar.
|
||
function scheduleLeafTextures(delay = 300) {
|
||
clearTimeout(leafTimer)
|
||
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay)
|
||
}
|
||
function nextLeafTexture() {
|
||
if (turning) return scheduleLeafTextures(400)
|
||
const n = shown.length
|
||
const near = []
|
||
for (let k = 1; k <= n; k++) near.push(shown.at(-1) + k, shown[0] - k)
|
||
const wanted = [...box.querySelectorAll('.stage img'), ...near.map(i => preloaded.get(i)).filter(Boolean)]
|
||
const keep = new Set(wanted.map(im => im.src))
|
||
for (const [src, tex] of leafTextures) if (!keep.has(src)) { leafGL?.gl.deleteTexture(tex); leafTextures.delete(src) }
|
||
const missing = wanted.filter(im => !leafTextures.has(im.src) && !leafNoTexture.has(im.src))
|
||
const im = missing.find(imgReady)
|
||
if (!im) { if (missing.length) scheduleLeafTextures(800); return }
|
||
if (!initLeafGL()) return
|
||
try { leafTexture(im) } catch { leafNoTexture.add(im.src) }
|
||
scheduleLeafTextures(120)
|
||
}
|
||
function freeLeafGL() {
|
||
leafGLOff = true
|
||
clearTimeout(leafTimer)
|
||
leafGL?.gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||
leafGL = null
|
||
leafTextures.clear()
|
||
}
|
||
// Devuelve null si no se puede (sin WebGL2 o sin textura): entonces se usa paperCurl.
|
||
function glCurl(r, frontIm, backIm, spineLeft, paper = r) {
|
||
if (!initLeafGL() || leafNoTexture.has(frontIm.src) || (backIm && leafNoTexture.has(backIm.src))) return null
|
||
const L = leafGL
|
||
const { gl, canvas, positions, uvs } = L
|
||
let front, back
|
||
try {
|
||
front = leafTexture(frontIm)
|
||
back = backIm ? leafTexture(backIm) : null
|
||
} catch { leafNoTexture.add(frontIm.src); if (backIm) leafNoTexture.add(backIm.src); return null }
|
||
const W = box.clientWidth, H = box.clientHeight, N = LEAF_COLS, R = LEAF_ROWS
|
||
const pw = Math.round(W * devicePixelRatio), ph = Math.round(H * devicePixelRatio)
|
||
if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph }
|
||
Object.assign(canvas.style, { width: `${W}px`, height: `${H}px` })
|
||
gl.viewport(0, 0, pw, ph)
|
||
gl.uniform3f(L.uView, W, H, parseFloat(getComputedStyle(box).perspective) || 2200)
|
||
gl.useProgram(L.shadowProg)
|
||
gl.uniform2f(L.uSize, W, H)
|
||
gl.useProgram(L.prog)
|
||
const scale = pw / W
|
||
gl.scissor(Math.round(paper.l * scale), Math.round((H - paper.t - paper.h) * scale), Math.round(paper.w * scale), Math.round(paper.h * scale))
|
||
gl.uniform1i(L.uPaper, back ? 0 : 1)
|
||
gl.uniform1i(L.uSpineLeft, spineLeft ? 1 : 0)
|
||
gl.uniform1i(L.uWide, frontIm.dataset.gutter === 'none' ? 1 : 0)
|
||
gl.activeTexture(gl.TEXTURE0)
|
||
gl.bindTexture(gl.TEXTURE_2D, front)
|
||
gl.activeTexture(gl.TEXTURE1)
|
||
gl.bindTexture(gl.TEXTURE_2D, back)
|
||
// Las columnas van del lomo al borde libre: con el lomo a la derecha, la malla queda al revés.
|
||
gl.frontFace(spineLeft ? gl.CCW : gl.CW)
|
||
for (let c = 0, n = 0; c <= N; c++) for (let f = 0; f <= R; f++) {
|
||
uvs[n++] = spineLeft ? c / N : 1 - c / N
|
||
uvs[n++] = f / R
|
||
}
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, L.uvBuf)
|
||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, uvs)
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf)
|
||
const curve = curlCurve(N)
|
||
const step = (spineLeft ? r.w : -r.w) / N
|
||
const sgn = spineLeft ? -1 : 1
|
||
const spineX = r.l + (spineLeft ? 0 : r.w)
|
||
let pivotY = r.h / 2
|
||
const rad = Math.PI / 180
|
||
const mul = (a, b) => [
|
||
a[0] * b[0] + a[1] * b[3] + a[2] * b[6], a[0] * b[1] + a[1] * b[4] + a[2] * b[7], a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
|
||
a[3] * b[0] + a[4] * b[3] + a[5] * b[6], a[3] * b[1] + a[4] * b[4] + a[5] * b[7], a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
|
||
a[6] * b[0] + a[7] * b[3] + a[8] * b[6], a[6] * b[1] + a[7] * b[4] + a[8] * b[7], a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
|
||
]
|
||
// Con la luz de frente, cada tira cae en vertical sobre el papel (x e y sin cambiar). La altura decide el borde:
|
||
// cerca del papel, nítido y oscuro; lejos, ancho y claro. Con MAX, los tramos que se solapan no oscurecen de más,
|
||
// y con la prueba de profundidad no se pinta lo que tapa la hoja.
|
||
function drawShadow(lift) {
|
||
const data = L.shadowData, col = (R + 1) * 4, strength = SHADOW_DARK * Math.min(1, lift * 4), fall = r.w * SHADOW_FALL
|
||
let n = 0
|
||
for (let c = 0; c < N; c += LEAF_SHADOW_STEP) {
|
||
const i = c * col, e = (c + LEAF_SHADOW_STEP) * col, down = R * 4
|
||
const ax = positions[i], ay = positions[i + 1], bx = positions[i + down], by = positions[i + down + 1], cx = positions[e], cy = positions[e + 1]
|
||
const len = Math.hypot(bx - ax, by - ay) || 1, vx = (bx - ax) / len, vy = (by - ay) / len
|
||
let ux = vy, uy = -vx
|
||
if ((cx - ax) * ux + (cy - ay) * uy < 0) { ux = -ux; uy = -uy }
|
||
for (let j = 0; j < 4; j++) for (let k = 0; k < 4; k++) {
|
||
const at = (j < 2 ? i : e) + (k < 2 ? 0 : down)
|
||
const height = Math.abs(positions[at + 2]), soft = 2 + height * SHADOW_SOFT
|
||
const oj = j === 0 ? -1 : j === 3 ? 1 : 0, ok = k === 0 ? -1 : k === 3 ? 1 : 0
|
||
data[n++] = positions[at] + (ux * oj + vx * ok) * soft
|
||
data[n++] = positions[at + 1] + (uy * oj + vy * ok) * soft
|
||
data[n++] = oj ? 0 : 1
|
||
data[n++] = ok ? 0 : 1
|
||
data[n++] = strength / (1 + height / fall)
|
||
}
|
||
}
|
||
gl.useProgram(L.shadowProg)
|
||
gl.bindVertexArray(L.shadowVao)
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, L.shadowBuf)
|
||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data)
|
||
gl.enable(gl.SCISSOR_TEST)
|
||
gl.enable(gl.BLEND)
|
||
gl.blendEquation(gl.MAX)
|
||
gl.blendFunc(gl.ONE, gl.ONE)
|
||
gl.depthMask(false)
|
||
gl.drawElements(gl.TRIANGLES, L.shadowCount, gl.UNSIGNED_SHORT, 0)
|
||
gl.depthMask(true)
|
||
gl.disable(gl.BLEND)
|
||
gl.disable(gl.SCISSOR_TEST)
|
||
gl.bindVertexArray(null)
|
||
gl.useProgram(L.prog)
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, L.posBuf)
|
||
}
|
||
// Devuelve cuánto está levantada la hoja (0 a 1). Encadena los giros de tira en
|
||
// tira como lo hacen las capas anidadas de paperCurl.
|
||
function set(g, reverse, tiltZ = 0, tiltX = 0, cam = 0) {
|
||
const a = curve.angles(g, reverse)
|
||
let m = [1, 0, 0, 0, 1, 0, 0, 0, 1], x = 0, y = 0, z = 0, prev = 0, prevC = 1, lift = 0, n = 0
|
||
const column = c => {
|
||
for (let f = 0; f <= R; f++) {
|
||
const v = (r.h * f) / R - pivotY
|
||
positions[n++] = spineX + cam + x + m[1] * v
|
||
positions[n++] = r.t + pivotY + y + m[4] * v
|
||
positions[n++] = z + m[7] * v
|
||
positions[n++] = c
|
||
}
|
||
}
|
||
for (let i = 0; i < N; i++) {
|
||
const c = Math.cos(a[i] * rad)
|
||
if (i) { x += m[0] * step; y += m[3] * step; z += m[6] * step }
|
||
const t = sgn * (a[i] - prev) * rad
|
||
m = mul(m, [Math.cos(t), 0, Math.sin(t), 0, 1, 0, -Math.sin(t), 0, Math.cos(t)])
|
||
if (i && (tiltZ || tiltX)) {
|
||
const tz = ((tiltZ * c) / (N - 1)) * rad, tx = ((tiltX * c) / (N - 1)) * rad
|
||
m = mul(mul(m, [Math.cos(tz), -Math.sin(tz), 0, Math.sin(tz), Math.cos(tz), 0, 0, 0, 1]), [1, 0, 0, 0, Math.cos(tx), -Math.sin(tx), 0, Math.sin(tx), Math.cos(tx)])
|
||
}
|
||
column(i ? (c + prevC) / 2 : c)
|
||
prev = a[i]
|
||
prevC = c
|
||
lift = Math.max(lift, Math.sin(a[i] * rad))
|
||
}
|
||
x += m[0] * step; y += m[3] * step; z += m[6] * step
|
||
column(prevC)
|
||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, positions)
|
||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
||
gl.drawElements(gl.TRIANGLES, L.indexCount, gl.UNSIGNED_SHORT, 0)
|
||
if (lift > 0) drawShadow(lift)
|
||
return lift
|
||
}
|
||
const pivot = y => { pivotY = y === 'bottom' ? r.h : y === 'top' ? 0 : r.h / 2 }
|
||
return { el: canvas, gl: true, set, span: curve.span, solve: curve.solve, pivot }
|
||
}
|
||
function paperCurl(r, frontSrc, backSrc, spineLeft, bent = false) {
|
||
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
|
||
const w = r.w / N
|
||
// Cada cara se sale OV px por los lados de su tira para tapar la unión con la vecina: sin solape,
|
||
// Safari deja una rendija oscura entre tiras.
|
||
// Curvada, las tiras dejan de encajar justas lejos del punto fijo: hace falta más solape.
|
||
const OV = bent ? 4 : 1.5
|
||
const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
|
||
const el = document.createElement('div')
|
||
el.className = 'curl'
|
||
const strips = []
|
||
let parent = el
|
||
for (let i = 0; i < N; i++) {
|
||
const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira
|
||
const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta
|
||
const s = document.createElement('div')
|
||
s.className = 'strip'
|
||
Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
|
||
// Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última
|
||
// columna). El reverso está girado sobre su centro: allí su izquierda es la derecha de la tira.
|
||
const l = fc > 0 ? OV : 0, rr = fc < N - 1 ? OV : 0
|
||
const face = (cls, src, col, off) => `<div class="sf ${cls}" style="left:${-l}px;right:${-rr}px">${src ? `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${off - col * w}px">` : ''}</div>`
|
||
s.innerHTML = face('front', frontSrc, fc, l) + face(backSrc ? 'back' : 'back paper', backSrc, bc, rr)
|
||
parent.append(s)
|
||
strips.push({ s, f: s.children[0], b: s.children[1] })
|
||
parent = s
|
||
}
|
||
const step = spineLeft ? w : -w
|
||
const { angles, span, solve } = curlCurve(N)
|
||
// Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
|
||
// tiltZ/tiltX: curvatura total de la hoja agarrada por arriba o por abajo. Se reparte entre las tiras salvo la
|
||
// del lomo, que no se curva, y el borde libre se lleva todo: la hoja sigue pegada al libro. Cambia de
|
||
// signo con la cara visible para que la parte agarrada vaya siempre por delante.
|
||
function set(g, reverse, tiltZ = 0, tiltX = 0) {
|
||
let prev = 0, lift = 0
|
||
angles(g, reverse).forEach((a, i) => {
|
||
const t = strips[i]
|
||
const c = Math.cos((a * Math.PI) / 180)
|
||
const bend = i && (tiltZ || tiltX) ? ` rotateZ(${((tiltZ * c) / (N - 1)).toFixed(3)}deg) rotateX(${((tiltX * c) / (N - 1)).toFixed(3)}deg)` : ''
|
||
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)${bend}`
|
||
prev = a
|
||
// Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari
|
||
// cada capa se compone aparte y en las uniones entre tiras asomaban líneas.
|
||
t.f.style.filter = `brightness(${(1 - (1 - c) * 0.3).toFixed(3)})`
|
||
t.b.style.filter = `brightness(${(1 - (1 + c) * 0.3).toFixed(3)})`
|
||
lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
|
||
})
|
||
return lift
|
||
}
|
||
// Punto fijo de la curvatura: el borde opuesto al agarrado (no afecta al giro sobre el eje vertical).
|
||
const pivot = y => strips.forEach(t => { t.s.style.transformOrigin = `${spineLeft ? 'left' : 'right'} ${y}` })
|
||
return { el, set, span, solve, pivot }
|
||
}
|
||
const GRAB_TILT_Z = 14, GRAB_TILT_X = 10 // curvatura total, en grados, de la hoja agarrada por arriba o por abajo
|
||
// drag: la página sigue al dedo; devuelve el control (move/release) en vez de animarse sola.
|
||
function turnPage(old, stage, dir, drag = false) {
|
||
// "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
|
||
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
|
||
const fitWidth = box.classList.contains('fit-width')
|
||
const pan = isPan(old)
|
||
let kind = pan ? 'pan' : settings.anim
|
||
const ready = im => im.complete && im.naturalWidth > 0
|
||
const oldImgs = [...old.querySelectorAll('img')]
|
||
// La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar.
|
||
if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide'
|
||
if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor
|
||
old.classList.add('leaving')
|
||
box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima
|
||
const double = oldImgs.length === 2 && shown.length === 2
|
||
// Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
|
||
if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
|
||
// En doble página, el reverso de la hoja es una página nueva: sin cargar, se verían solo las sombras
|
||
// de las tiras (líneas oscuras).
|
||
if (kind === 'flip' && double && ![...stage.querySelectorAll('img')].every(ready)) kind = 'slide'
|
||
const extra = []
|
||
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' }
|
||
// Al soltar el dedo sin pasar la página: queda la vieja como estaba.
|
||
const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '', background: '' }) }
|
||
const easeOut = t => 1 - (1 - t) ** 3
|
||
// Tapa lo que hay a los lados de la página, para que las vecinas no aparezcan de golpe en una pantalla más ancha.
|
||
const addSideMasks = width => {
|
||
const side = (box.clientWidth - width) / 2
|
||
if (side <= 0.5) return
|
||
for (const edge of ['left', 'right']) {
|
||
const mask = document.createElement('i')
|
||
mask.className = 'panmask'
|
||
Object.assign(mask.style, { [edge]: '0', width: `${side}px` })
|
||
box.append(mask)
|
||
extra.push(mask)
|
||
}
|
||
}
|
||
// step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance.
|
||
const B = box.getBoundingClientRect()
|
||
const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
|
||
const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
|
||
const grabOf = r => {
|
||
const at = grabY != null ? (grabY - B.top - r.t) / r.h : 0.5
|
||
return at < 1 / 3 ? -1 : at > 2 / 3 ? 1 : 0
|
||
}
|
||
const newIm = stage.querySelector('img')
|
||
let bookCurl = null
|
||
if (kind === 'flip' && ready(newIm) && isBookTurn(old, dir)) {
|
||
bookCurl = glCurl(rel(oldImgs[0]), oldImgs[0], newIm, sign > 0, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight })
|
||
}
|
||
let step, dur, range = box.clientWidth, toG = q => q, auto = easeOut, g0 = 0, g1 = 1
|
||
if (kind === 'fade') {
|
||
step = g => { old.style.opacity = String(1 - g) }
|
||
dur = 180
|
||
} else if (kind === 'slide') {
|
||
step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` }
|
||
dur = 260
|
||
} else if (kind === 'pan') {
|
||
// Las dos páginas van juntas, lomo con lomo: la nueva empuja a la vieja. Lo que se vea a los lados de la página
|
||
// queda tapado, para que la nueva no aparezca de golpe si la pantalla es más ancha que ella.
|
||
const oldW = oldImgs[0].getBoundingClientRect().width
|
||
const newW = ready(newIm) ? newIm.getBoundingClientRect().width : oldW
|
||
const shift = (oldW + newW) / 2
|
||
addSideMasks(Math.max(oldW, newW))
|
||
old.style.background = 'transparent'
|
||
step = g => { old.style.transform = `translateX(${-sign * shift * g}px)`; stage.style.transform = `translateX(${sign * shift * (1 - g)}px)` }
|
||
step(0)
|
||
dur = 460
|
||
range = shift
|
||
auto = t => (t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) ** 2)
|
||
} else if (bookCurl) {
|
||
// Como un libro: la hoja gira sobre el lomo y descubre la página que tiene debajo, la vista la acompaña y la
|
||
// nueva, en el reverso de la hoja, cae en su sitio. Alrededor están las páginas vecinas, lomo con lomo.
|
||
const rect = rel(oldImgs[0]), spineLeft = sign > 0
|
||
const hinge = spineLeft ? rect.l : rect.l + rect.w
|
||
const newRect = rel(newIm)
|
||
const travel = (spineLeft ? newRect.l + newRect.w : newRect.l) - hinge
|
||
const from = Number(oldImgs[0].dataset.i), to = shown[0]
|
||
const under = []
|
||
const neighbour = (i, onHingeSide) => {
|
||
if (i < 0 || i >= total || wide.has(i)) return
|
||
const im = preloaded.get(i) || Object.assign(new Image(), { src: pages[i] })
|
||
const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0
|
||
const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h
|
||
im.className = 'leafkeep'
|
||
im.alt = ''
|
||
im.dataset.gutter = spineLeft === onHingeSide ? 'l' : 'r'
|
||
place(im, { l: spineLeft === onHingeSide ? hinge : hinge - w, t: (box.clientHeight - h) / 2, w, h })
|
||
im.style.willChange = 'transform'
|
||
under.push(im)
|
||
}
|
||
const back = document.createElement('i')
|
||
back.className = 'leafkeepback'
|
||
place(back, { l: 0, t: 0, w: box.clientWidth, h: box.clientHeight })
|
||
neighbour(to + dir, true)
|
||
neighbour(from - dir, false)
|
||
box.append(back, ...under, bookCurl.el)
|
||
extra.push(back, ...under, bookCurl.el)
|
||
addSideMasks(Math.max(rect.w, newRect.w))
|
||
old.style.visibility = 'hidden'
|
||
const grab = grabOf(rect)
|
||
if (grab) bookCurl.pivot(grab < 0 ? 'bottom' : 'top')
|
||
step = g => {
|
||
const follow = Math.min(1, Math.max(0, (g - BOOK_FOLLOW_FROM) / (1 - BOOK_FOLLOW_FROM)))
|
||
const cam = travel * follow * follow * (3 - 2 * follow)
|
||
for (const im of under) im.style.transform = `translateX(${cam}px)`
|
||
const k = grab ? grab * Math.sin(Math.PI * g) : 0
|
||
bookCurl.set(g, false, (spineLeft ? 1 : -1) * GRAB_TILT_Z * k, GRAB_TILT_X * k, cam)
|
||
}
|
||
step(0)
|
||
dur = 850
|
||
auto = t => t
|
||
range = rect.w
|
||
toG = q => bookCurl.solve(1 - q * 2, false)
|
||
} else {
|
||
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
|
||
// Medidas relativas al contenedor.
|
||
const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
|
||
let rect, paper, frontIm, backIm = null, spineLeft, reverse = false
|
||
if (double) {
|
||
// Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
|
||
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
|
||
// encima de la nueva) hasta que la hoja cae.
|
||
const oldX = byX(oldImgs)
|
||
// Las nuevas puede que aún no estén cargadas (sin tamaño ni posición): su lado sale del orden de lectura.
|
||
const newX = [...stage.querySelectorAll('img')]
|
||
if (settings.dir === 'rtl') newX.reverse()
|
||
const front = sign > 0 ? oldX.at(-1) : oldX[0]
|
||
const stay = sign > 0 ? oldX[0] : oldX.at(-1)
|
||
rect = rel(front)
|
||
const other = rel(stay)
|
||
const left = Math.min(rect.l, other.l), top = Math.min(rect.t, other.t)
|
||
paper = { l: left, t: top, w: Math.max(rect.l + rect.w, other.l + other.w) - left, h: Math.max(rect.t + rect.h, other.t + other.h) - top }
|
||
frontIm = front
|
||
backIm = sign > 0 ? newX[0] : newX.at(-1)
|
||
spineLeft = sign > 0
|
||
const keep = document.createElement('img')
|
||
keep.className = 'leafkeep'
|
||
keep.dataset.gutter = stay.dataset.gutter
|
||
keep.src = stay.src
|
||
place(keep, rel(stay))
|
||
// Su hendidura es transparente: sin negro detrás, se vería la página nueva y la hendidura se aclararía.
|
||
const keepBack = document.createElement('i')
|
||
keepBack.className = 'leafkeepback'
|
||
place(keepBack, rel(stay))
|
||
box.append(keepBack, keep)
|
||
extra.push(keepBack, keep)
|
||
old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar
|
||
} else {
|
||
// Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la
|
||
// página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo
|
||
// gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa.
|
||
const img = dir > 0 ? oldImgs[0] : stage.querySelector('img')
|
||
rect = rel(img)
|
||
frontIm = img
|
||
spineLeft = settings.dir !== 'rtl'
|
||
reverse = dir < 0
|
||
if (dir > 0) old.style.visibility = 'hidden'
|
||
}
|
||
const grab = grabOf(rect)
|
||
const curl = glCurl(rect, frontIm, backIm, spineLeft, paper) || paperCurl(rect, frontIm.src, backIm?.src || null, spineLeft, !!grab)
|
||
// Sin WebGL, la sombra de debajo es un degradado desde el lomo.
|
||
let drop = null
|
||
if (!curl.gl) {
|
||
place(curl.el, rect)
|
||
drop = document.createElement('i')
|
||
drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
|
||
place(drop, rect)
|
||
box.append(drop)
|
||
extra.push(drop)
|
||
}
|
||
box.append(curl.el)
|
||
extra.push(curl.el)
|
||
;[g0, g1] = double ? [0, 1] : curl.span(reverse)
|
||
// Tocada o agarrada por arriba o por abajo (tercio superior o inferior), esa parte va por delante: la hoja se curva
|
||
// sin separarse del lomo. Sin capas nuevas; es 0 al principio y al final del giro, así que cae exacta.
|
||
// Por el centro, como siempre.
|
||
if (grab) curl.pivot(grab < 0 ? 'bottom' : 'top')
|
||
step = g => {
|
||
const k = grab ? grab * Math.sin(Math.PI * Math.min(1, Math.max(0, (g - g0) / (g1 - g0 || 1)))) : 0
|
||
const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k)
|
||
if (drop) drop.style.opacity = (lift * 0.8).toFixed(3)
|
||
}
|
||
dur = double ? 750 : 610
|
||
auto = t => g0 + (g1 - g0) * t
|
||
// El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble);
|
||
// hacia atrás en una página, del lomo a su sitio.
|
||
range = double ? 2 * rect.w : rect.w
|
||
toG = q => curl.solve(reverse ? q : 1 - q * (double ? 2 : 1), reverse)
|
||
}
|
||
if (drag) {
|
||
let g = toG(0)
|
||
step(g)
|
||
const ctl = {
|
||
anims: [], cleanup, range, pan,
|
||
move(q) { g = toG(Math.min(1, Math.max(0, q))); step(g) },
|
||
// Termina de pasar la página (commit) o la devuelve a su sitio; done(commit) al acabar.
|
||
release(commit, done) {
|
||
const from = g, to = commit ? g1 : g0
|
||
const a = frameAnim(Math.max(120, (dur * Math.abs(to - from)) / (g1 - g0 || 1)), t => { g = from + (to - from) * easeOut(t); step(g) })
|
||
ctl.anims = [a]
|
||
a.finished.then(() => {
|
||
if (turning !== ctl) return
|
||
turning = null
|
||
if (commit) cleanup()
|
||
else revert()
|
||
done(commit)
|
||
}, () => {})
|
||
},
|
||
}
|
||
turning = ctl
|
||
return
|
||
}
|
||
const anims = [frameAnim(dur, t => step(auto(t)))]
|
||
turning = { anims, cleanup }
|
||
anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
|
||
}
|
||
function showEnd() {
|
||
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) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
|
||
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
|
||
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
|
||
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
|
||
}), { 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 && !g.ctl.pan) turnSound(settings.sfx, turnPan(g.dir))
|
||
g.ctl.release(commit, ok => {
|
||
if (ok) return
|
||
shown = g.prev
|
||
page = g.prev[0]
|
||
setPageLabel()
|
||
scheduleSave()
|
||
})
|
||
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
|
||
refreshGutters()
|
||
syncDirUi()
|
||
savePrefs()
|
||
})
|
||
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
|
||
$('#anim').value = settings.anim
|
||
$('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
|
||
$('#sfx').value = SOUND_LEVELS[settings.sfx] ? settings.sfx : 'mid'
|
||
$('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) })
|
||
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
|
||
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
|
||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, 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 y se ajusta con los puntos de sus esquinas. Se recorta
|
||
// del original (no de la versión HD) y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas
|
||
// del documento: al llevar un punto al borde de la pantalla la lectura se desplaza y la zona sigue creciendo, y
|
||
// en lectura vertical puede abarcar varias páginas, que se unen en una sola imagen.
|
||
const CROP_MAX_SIDE = 8000 // el mismo límite que en el servidor (clips.js)
|
||
const CROP_MAX_AREA = 16e6 // por encima, Safari en iOS puede no generar el canvas
|
||
let crop = null
|
||
function endCrop() {
|
||
if (!crop) return
|
||
cancelAnimationFrame(crop.raf)
|
||
crop.ov.remove()
|
||
window.removeEventListener('scroll', crop.draw)
|
||
crop = null
|
||
$('#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 = `<div class="crop-bar"><span>${icon('scissors')} <em>Arrastra sobre el panel que quieres guardar</em></span>
|
||
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
|
||
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
|
||
<div class="crop-rect" hidden>${['nw', 'ne', 'sw', 'se'].map(h => `<i class="crop-h" data-h="${h}"></i>`).join('')}</div>`
|
||
reader.append(ov)
|
||
$('#clipbtn').classList.add('on')
|
||
const rectEl = $('.crop-rect', ov), saveBtn = $('[data-a=save]', ov), hint = $('.crop-bar em', ov)
|
||
crop = { ov, r: null, start: null, prev: null, pan: null, pid: null, off: { x: 0, y: 0 }, last: null, raf: 0 }
|
||
crop.draw = () => {
|
||
const r = crop?.r
|
||
if (!r) return
|
||
Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` })
|
||
rectEl.hidden = false
|
||
}
|
||
const canScroll = () => document.documentElement.scrollHeight > innerHeight + 1
|
||
// Lleva la esquina que se arrastra hasta el dedo; la opuesta (crop.start) se queda fija.
|
||
const stretch = () => {
|
||
const x = crop.last.x + scrollX + crop.off.x, y = crop.last.y + scrollY + crop.off.y
|
||
crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) }
|
||
crop.draw()
|
||
}
|
||
// Con el dedo cerca del borde de arriba o de abajo, la lectura se desplaza y la zona sigue creciendo.
|
||
const EDGE = 80, EDGE_SPEED = 22
|
||
const autoScroll = () => {
|
||
if (!crop?.start) return
|
||
const y = crop.last.y
|
||
const v = y < EDGE ? -(EDGE - y) / EDGE : y > innerHeight - EDGE ? (y - innerHeight + EDGE) / EDGE : 0
|
||
if (v && crop.r) {
|
||
scrollBy(0, v * EDGE_SPEED)
|
||
stretch()
|
||
}
|
||
crop.raf = requestAnimationFrame(autoScroll)
|
||
}
|
||
window.addEventListener('scroll', crop.draw, { passive: true })
|
||
ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false })
|
||
ov.addEventListener('pointerdown', e => {
|
||
if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0) || crop.pid != null) return
|
||
const x = e.clientX + scrollX, y = e.clientY + scrollY, r = crop.r
|
||
const handle = e.target.closest('.crop-h')?.dataset.h
|
||
crop.pid = e.pointerId
|
||
ov.setPointerCapture(e.pointerId)
|
||
// Con el dedo la lectura no se puede desplazar de otro modo: deslizar dentro de la zona la mueve, para llegar
|
||
// a las esquinas que quedan fuera de la pantalla.
|
||
if (!handle && e.pointerType !== 'mouse' && r && canScroll() && x >= r.l && x <= r.r && y >= r.t && y <= r.b) {
|
||
crop.pan = e.clientY
|
||
return
|
||
}
|
||
if (handle) {
|
||
const cx = handle[1] === 'w' ? r.l : r.r, cy = handle[0] === 'n' ? r.t : r.b
|
||
crop.start = { x: handle[1] === 'w' ? r.r : r.l, y: handle[0] === 'n' ? r.b : r.t }
|
||
crop.off = { x: cx - x, y: cy - y }
|
||
crop.prev = r
|
||
} else {
|
||
crop.start = { x, y }
|
||
crop.off = { x: 0, y: 0 }
|
||
crop.prev = null
|
||
crop.r = null
|
||
rectEl.hidden = true
|
||
saveBtn.disabled = true
|
||
}
|
||
crop.last = { x: e.clientX, y: e.clientY }
|
||
crop.raf = requestAnimationFrame(autoScroll)
|
||
})
|
||
ov.addEventListener('pointermove', e => {
|
||
if (e.pointerId !== crop.pid) return
|
||
if (crop.pan != null) {
|
||
scrollBy(0, crop.pan - e.clientY)
|
||
crop.pan = e.clientY
|
||
return
|
||
}
|
||
if (!crop.start) return
|
||
crop.last = { x: e.clientX, y: e.clientY }
|
||
stretch()
|
||
})
|
||
const up = e => {
|
||
if (!crop || e.pointerId !== crop.pid) return
|
||
crop.pid = null
|
||
crop.pan = null
|
||
if (!crop.start) return
|
||
cancelAnimationFrame(crop.raf)
|
||
crop.start = null
|
||
// Demasiado pequeña: al ajustar vuelve a como estaba; al marcarla de cero, no queda nada.
|
||
if (!crop.r || crop.r.r - crop.r.l < 12 || crop.r.b - crop.r.t < 12) crop.r = crop.prev
|
||
if (crop.r) crop.draw()
|
||
else rectEl.hidden = true
|
||
saveBtn.disabled = !crop.r
|
||
if (crop.r) hint.textContent = canScroll() ? 'Mueve los puntos; llévalos al borde para seguir' : 'Ajusta la zona con los puntos'
|
||
}
|
||
ov.addEventListener('pointerup', up)
|
||
ov.addEventListener('pointercancel', up)
|
||
$('[data-a=cancel]', ov).addEventListener('click', endCrop)
|
||
saveBtn.addEventListener('click', e => saveCrop(e.target.closest('button')))
|
||
}
|
||
// Las imágenes bajo el rectángulo y la zona de cada una, en píxeles de pantalla desde su esquina. Por páginas, solo
|
||
// la de mayor intersección; en vertical, todas las que toca, de arriba abajo.
|
||
function cropParts(r) {
|
||
const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY }
|
||
const hits = []
|
||
for (const img of box.querySelectorAll('img')) {
|
||
const b = img.getBoundingClientRect()
|
||
const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top)
|
||
if (w <= 0 || h <= 0) continue
|
||
hits.push({ img, b, area: w * h, x0: Math.max(c.l, b.left) - b.left, y0: Math.max(c.t, b.top) - b.top, x1: Math.min(c.r, b.right) - b.left, y1: Math.min(c.b, b.bottom) - b.top })
|
||
}
|
||
const ready = hit => hit.img.complete && hit.img.naturalWidth > 0
|
||
if (isPaged()) {
|
||
const loaded = hits.filter(ready)
|
||
return loaded.length ? [loaded.reduce((a, h) => (h.area > a.area ? h : a))] : []
|
||
}
|
||
if (!hits.every(ready)) throw new Error('Espera a que carguen las páginas de la zona')
|
||
return hits
|
||
}
|
||
async function saveCrop(btn) {
|
||
btn.disabled = true
|
||
try {
|
||
const parts = crop?.r ? cropParts(crop.r) : []
|
||
if (!parts.length) throw new Error('Marca una zona sobre la página')
|
||
// Del original: el servidor lo reescala él mismo.
|
||
const sources = await Promise.all(parts.map(({ img }) => new Promise((resolve, reject) => {
|
||
const u = new URL(img.currentSrc || img.src, location.href)
|
||
u.searchParams.delete('hd')
|
||
const im = new Image()
|
||
im.onload = () => resolve(im)
|
||
im.onerror = () => reject(new Error('No se pudo cargar la página'))
|
||
im.src = u.href
|
||
})))
|
||
// Misma escala para todas (la de la página con más detalle), una debajo de otra y sin los huecos de la lectura.
|
||
const scale = Math.max(...parts.map((h, i) => sources[i].naturalWidth / h.b.width))
|
||
const left = Math.min(...parts.map(h => h.b.left + h.x0)), right = Math.max(...parts.map(h => h.b.left + h.x1))
|
||
const fullW = (right - left) * scale, fullH = parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * scale
|
||
if (fullW < 16 || fullH < 16) throw new Error('La zona es demasiado pequeña')
|
||
const k = scale * Math.min(1, CROP_MAX_SIDE / Math.max(fullW, fullH), Math.sqrt(CROP_MAX_AREA / (fullW * fullH)))
|
||
const cv = document.createElement('canvas')
|
||
cv.width = Math.round((right - left) * k)
|
||
cv.height = Math.round(parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * k)
|
||
const ctx = cv.getContext('2d')
|
||
ctx.imageSmoothingQuality = 'high'
|
||
if (parts.length > 1) { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height) } // páginas de distinto ancho
|
||
let y = 0
|
||
parts.forEach((h, i) => {
|
||
const src = sources[i], fx = src.naturalWidth / h.b.width, fy = src.naturalHeight / h.b.height
|
||
const top = Math.round(y * k)
|
||
y += h.y1 - h.y0
|
||
ctx.drawImage(src, Math.round(h.x0 * fx), Math.round(h.y0 * fy), Math.max(1, Math.round((h.x1 - h.x0) * fx)), Math.max(1, Math.round((h.y1 - h.y0) * fy)),
|
||
Math.round((h.b.left + h.x0 - left) * k), top, Math.round((h.x1 - h.x0) * k), Math.round(y * k) - top)
|
||
})
|
||
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
|
||
if (!blob) throw new Error('La zona es demasiado grande para este dispositivo')
|
||
const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, 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' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
|
||
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
|
||
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
|
||
async function viewClips(params) {
|
||
const mangaId = params.get('manga')
|
||
const here = () => location.hash.split('?')[0] === '#/clips'
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`)
|
||
if (!here()) return
|
||
const total = data.mangas.reduce((a, m) => a + m.n, 0)
|
||
app.innerHTML = `<h1>${icon('scissors')} Recortes</h1>
|
||
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
|
||
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
|
||
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
|
||
<div id="cgrid" class="clips"></div>
|
||
<div id="lb" class="lightbox" hidden></div>`
|
||
app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' }))
|
||
const grid = $('#cgrid'), lb = $('#lb')
|
||
let open = -1, showHd = true
|
||
|
||
function renderGrid() {
|
||
grid.innerHTML = data.clips.length ? data.clips.map((c, i) => `
|
||
<figure class="clip" data-i="${i}">
|
||
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
|
||
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
|
||
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
|
||
</figure>`).join('')
|
||
: `<p class="muted">Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.</p>`
|
||
grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i))))
|
||
}
|
||
function openClip(i) {
|
||
open = i
|
||
const c = data.clips[i]
|
||
if (!c) return closeClip()
|
||
const src = c.hd && showHd ? c.hd : c.img
|
||
const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}`
|
||
lb.innerHTML = `
|
||
<div class="lb-img${c.height / c.width > 2.5 ? ' long' : ''}"><img src="${src}" alt=""></div>
|
||
<div class="lb-side">
|
||
<div class="row" style="justify-content:space-between">
|
||
<span class="muted">${i + 1} / ${data.clips.length}</span>
|
||
<span class="row" style="gap:6px">
|
||
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
|
||
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
|
||
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
|
||
</span>
|
||
</div>
|
||
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
|
||
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
|
||
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
|
||
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
|
||
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
|
||
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
|
||
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
|
||
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
|
||
<div class="row" style="gap:6px;flex-wrap:wrap">
|
||
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
|
||
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
|
||
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
|
||
</div>
|
||
</div>`
|
||
lb.hidden = false
|
||
document.body.classList.add('noscroll')
|
||
lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1))
|
||
lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1))
|
||
lb.querySelector('[data-a=close]').addEventListener('click', closeClip)
|
||
$('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) })
|
||
$('#note', lb).addEventListener('change', async e => {
|
||
try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) }
|
||
})
|
||
lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => {
|
||
try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) }
|
||
})
|
||
lb.querySelector('[data-a=del]').addEventListener('click', async () => {
|
||
if (!confirm('¿Eliminar este recorte?')) return
|
||
try {
|
||
await api(`/api/clips/${c.id}`, { method: 'DELETE' })
|
||
data.clips.splice(open, 1)
|
||
renderGrid()
|
||
data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip()
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
}
|
||
function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') }
|
||
lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() })
|
||
const onKey = e => {
|
||
if (open < 0 || e.target.matches('input')) return
|
||
if (e.key === 'Escape') closeClip()
|
||
else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1)
|
||
else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1)
|
||
}
|
||
document.addEventListener('keydown', onKey)
|
||
|
||
// Mientras haya recortes en cola, refrescar su estado.
|
||
let timer = null
|
||
function poll() {
|
||
clearTimeout(timer)
|
||
if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return
|
||
timer = setTimeout(async () => {
|
||
if (!here()) return
|
||
const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null)
|
||
if (!fresh || !here()) return
|
||
const key = d => d.clips.map(c => `${c.id}:${c.status}`).join()
|
||
if (key(fresh) !== key(data)) {
|
||
const openId = data.clips[open]?.id
|
||
data = fresh
|
||
renderGrid()
|
||
if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() }
|
||
}
|
||
poll()
|
||
}, 3000)
|
||
}
|
||
renderGrid()
|
||
poll()
|
||
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
|
||
}
|
||
|
||
// ---------- Librería ----------
|
||
// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias.
|
||
const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="card hcard" href="${href}">
|
||
<img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback>
|
||
${badge ? `<span class="badge corner">${badge}</span>` : ''}
|
||
${del ? `<button class="hdel" ${del.attrs} title="${esc(del.title)}" aria-label="${esc(del.title)}">${icon(del.icon || 'trash')}</button>` : ''}
|
||
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${pct || 0}%"></i></div>
|
||
<div class="t">${esc(title)}</div>
|
||
${sub ? `<div class="sub">${sub}</div>` : ''}
|
||
</a>`
|
||
async function viewLibrary(params) {
|
||
const tab = params.get('tab') || 'reading'
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
const [hist, fol, lists] = serverReachable
|
||
? await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
|
||
: [null, null, []]
|
||
if (!location.hash.startsWith('#/library')) return
|
||
const online = !!hist
|
||
const dev = OFFLINE_OK ? offByManga() : []
|
||
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 = `<h1>Librería</h1>
|
||
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
|
||
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
|
||
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
|
||
<button class="btn small" id="lren">${icon('pencil')} Renombrar</button><button class="btn small danger" id="ldel">${icon('trash')} Eliminar lista</button></div>` : ''}
|
||
<div id="libbody"><div class="loading">Cargando…</div></div>`
|
||
app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` }))
|
||
const body = $('#libbody')
|
||
const grid = (cards, empty) => (cards.length ? `<div class="grid">${cards.join('')}</div>` : `<p class="muted">${empty}</p>`)
|
||
const histCard = h => libCard({
|
||
href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title,
|
||
pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0,
|
||
badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '',
|
||
sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`,
|
||
del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' },
|
||
})
|
||
if (!online && tab !== 'device') {
|
||
body.innerHTML = `<p class="muted">Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}</p>`
|
||
+ (dev.length ? `<p><a class="btn" href="#/library?tab=device">${icon('phone')} En el dispositivo</a></p>` : '')
|
||
} else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.')
|
||
else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.')
|
||
else if (tab === 'following') {
|
||
body.innerHTML = grid(follows.map(f => libCard({
|
||
href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '',
|
||
sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún',
|
||
del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' },
|
||
})), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.')
|
||
} else if (tab === 'device') {
|
||
const total = dev.reduce((n, m) => n + m.bytes, 0)
|
||
body.innerHTML = (dev.length ? `<p class="muted small">${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.</p>` : '')
|
||
+ grid(dev.map(m => {
|
||
const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0))
|
||
return libCard({
|
||
href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title,
|
||
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
|
||
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
|
||
})
|
||
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
|
||
} else if (curList) {
|
||
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
||
body.innerHTML = grid(l.items.map(it => libCard({
|
||
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
|
||
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
|
||
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
|
||
} else body.innerHTML = '<p class="muted">Esa lista ya no existe.</p>'
|
||
|
||
const reload = () => viewLibrary(params)
|
||
body.addEventListener('click', async e => {
|
||
const b = e.target.closest('button.hdel')
|
||
if (!b) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
if (b.dataset.delhist) {
|
||
if (!confirm('¿Borrar el historial de este manga?')) return
|
||
await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' })
|
||
} else if (b.dataset.unfollow) {
|
||
await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' })
|
||
toast('Ya no sigues este manga')
|
||
} else if (b.dataset.deldev) {
|
||
const m = dev.find(x => x.mangaId === b.dataset.deldev)
|
||
if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return
|
||
await offRemove(m.chapters.map(c => c.chapterId))
|
||
} else if (b.dataset.unlist) {
|
||
await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' })
|
||
}
|
||
reload()
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
$('#newlist')?.addEventListener('click', async () => {
|
||
const name = prompt('Nombre de la nueva lista:')
|
||
if (!name?.trim()) return
|
||
try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) }
|
||
})
|
||
$('#lren')?.addEventListener('click', async () => {
|
||
const name = prompt('Nuevo nombre de la lista:', curList.name)
|
||
if (!name?.trim()) return
|
||
try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) }
|
||
})
|
||
$('#ldel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return
|
||
try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) }
|
||
})
|
||
}
|
||
|
||
// ---------- Descargas ----------
|
||
// Actualiza `from` para que quede igual que `to` tocando solo lo que difiere. Los hijos con data-key se
|
||
// emparejan por clave (una tarjeta que cambia de posición conserva su <img> y no recarga la portada).
|
||
function morph(from, to, childrenOnly = false) {
|
||
if (!childrenOnly) {
|
||
if (from.nodeType !== to.nodeType || from.nodeName !== to.nodeName) { from.replaceWith(to); return to }
|
||
if (from.nodeType !== 1) { if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue; return from }
|
||
for (const { name } of [...from.attributes]) if (!to.hasAttribute(name) && name !== 'data-failed') from.removeAttribute(name)
|
||
for (const { name, value } of [...to.attributes]) {
|
||
if (name === 'src' && from.dataset.failed) continue // portada que no cargó: se queda el icono
|
||
if (from.getAttribute(name) !== value) from.setAttribute(name, value)
|
||
}
|
||
}
|
||
const byKey = new Map([...from.children].filter(n => n.dataset.key).map(n => [n.dataset.key, n]))
|
||
const kids = [...to.childNodes]
|
||
kids.forEach((nk, i) => {
|
||
const key = nk.nodeType === 1 ? nk.dataset.key : null
|
||
const at = from.childNodes[i]
|
||
let old = key ? byKey.get(key) : at && !(at.nodeType === 1 && at.dataset.key) ? at : null
|
||
if (key) byKey.delete(key)
|
||
const node = old ? morph(old, nk) : nk
|
||
if (from.childNodes[i] !== node) from.insertBefore(node, from.childNodes[i] || null)
|
||
})
|
||
while (from.childNodes.length > kids.length) from.lastChild.remove()
|
||
return from
|
||
}
|
||
|
||
async function viewQueues() {
|
||
if (cleanup) { cleanup(); cleanup = null }
|
||
const here = () => location.hash === '#/queues'
|
||
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
let q, list
|
||
try {
|
||
[q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
|
||
} catch (err) {
|
||
// Sin servidor: al menos la cola de este dispositivo (que sigue por su cuenta si hay red a medias).
|
||
if (!OFFLINE_OK || !here()) throw err
|
||
q = { downloads: [], upscale: { available: true, groups: [], current: null }, clips: { queued: 0, running: 0, errors: 0 }, covers: 0, offline: err.message }
|
||
list = []
|
||
}
|
||
if (!here()) return
|
||
const up = q.upscale
|
||
const library = list.filter(d => !DL_ACTIVE.includes(d.status))
|
||
const hdQueued = up.groups.reduce((n, g) => n + g.queued, 0)
|
||
const total = list.reduce((n, d) => n + (d.size || 0), 0)
|
||
const extra = [
|
||
q.clips.queued + q.clips.running ? `<a href="#/clips">${icon('scissors')} ${plural(q.clips.queued + q.clips.running, 'recorte')} reescalándose</a>` : '',
|
||
q.clips.errors ? `<a href="#/clips" class="error">${plural(q.clips.errors, 'recorte')} con error</a>` : '',
|
||
q.covers ? `<span>${icon('image')} ${plural(q.covers, 'portada')} en cola</span>` : '',
|
||
].filter(Boolean)
|
||
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
|
||
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
|
||
<div class="card hcard qcard" data-key="${esc(key)}">
|
||
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback></a>
|
||
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
|
||
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
|
||
<a class="t" href="#/manga/${id}">${esc(title)}</a>
|
||
<div class="sub">${lines}</div>
|
||
${actions ? `<div class="qact">${actions}</div>` : ''}
|
||
</div>`
|
||
// Guardado en este dispositivo: lo hace el navegador, no el servidor (offState).
|
||
const devHtml = () => {
|
||
if (!OFFLINE_OK) return ''
|
||
const items = [...(offState.running ? [offState.running] : []), ...offState.queue]
|
||
const groups = new Map()
|
||
for (const it of items) {
|
||
const g = groups.get(it.mangaId) || { ...it, chapters: [] }
|
||
g.chapters.push(it)
|
||
groups.set(it.mangaId, g)
|
||
}
|
||
const saved = Object.values(offIdx())
|
||
const bytes = saved.reduce((n, c) => n + (c.bytes || 0), 0)
|
||
return `<h2>${icon('phone')} En este dispositivo ${items.length ? `<span class="badge">${items.length}</span>` : ''}</h2>
|
||
<p class="muted small">Capítulos que se guardan en este ${/Mobi|Android|iPhone/i.test(navigator.userAgent) ? 'móvil' : 'navegador'} para leer sin conexión; la cola sigue mientras la app esté abierta.
|
||
${saved.length ? `Guardados: <a href="#/library?tab=device">${plural(saved.length, 'capítulo')} · ${fmtSize(bytes)}</a>.` : ''}</p>
|
||
${groups.size ? `<div class="grid qgrid">${[...groups.values()].map((g, i) => {
|
||
const cur = offState.running?.mangaId === g.mangaId ? offState.running : null
|
||
const rest = g.chapters.length - (cur ? 1 : 0)
|
||
return qcard({
|
||
key: `dev:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.mangaTitle,
|
||
pct: cur?.total ? (cur.done / cur.total) * 100 : null,
|
||
badge: cur ? icon('phone') : i + 1,
|
||
lines: `${cur ? `<div>${esc(cur.label)} · pág. ${cur.done}/${cur.total || '?'}</div>` : ''}
|
||
${rest ? `<div>${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola</div>` : ''}`,
|
||
actions: `<button class="btn small" data-devcancel="${esc(g.mangaId)}">${icon('stop')} Cancelar</button>`,
|
||
})
|
||
}).join('')}</div>` : '<p class="muted">No se está guardando nada. Se añade desde la ficha («⋯ → En este dispositivo») o desde el lector.</p>'}`
|
||
}
|
||
const html = q.offline ? `<h1>Colas</h1><p class="error">${esc(q.offline)}: no se pueden ver las colas del servidor.</p>
|
||
<section class="qsec" id="devq">${devHtml()}</section>` : `<h1>Colas</h1>
|
||
<section class="qsec">
|
||
<h2>${icon('download')} Descargas ${q.downloads.length ? `<span class="badge">${q.downloads.length}</span>` : ''}</h2>
|
||
${q.downloads.length ? `<div class="grid qgrid">${q.downloads.map((d, i) => qcard({
|
||
key: `dl:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title, pct: downloadPct(d),
|
||
badge: d.status === 'downloading' ? icon('download') : i + 1,
|
||
lines: `<div>${esc(downloadLabel(d))} · ${Math.floor(downloadPct(d))}%</div>
|
||
${d.status === 'downloading' ? `<div>${downloadDetail(d)}</div>` : ''}${d.scope === 'some' ? '<div>Capítulos elegidos</div>' : ''}`,
|
||
actions: `<button class="btn small" data-cancel="${esc(d.manga_id)}">${icon('stop')} Cancelar</button>`,
|
||
})).join('')}</div>` : '<p class="muted">No hay descargas en curso. Se añaden desde la ficha de un manga.</p>'}
|
||
</section>
|
||
${OFFLINE_OK ? `<section class="qsec" id="devq">${devHtml()}</section>` : ''}
|
||
<section class="qsec">
|
||
<h2>${icon('sparkles')} Reescalado HD ${hdQueued ? `<span class="badge">${hdQueued}</span>` : ''}</h2>
|
||
${up.available ? '' : '<p class="muted">El reescalado no está disponible en este servidor (falta Real-ESRGAN o la GPU).</p>'}
|
||
${up.groups.length ? `${hdQueued ? `<p class="muted small">Un capítulo cada vez, en orden de llegada · ${up.secPerPage} s por página · quedan ${fmtDur(up.eta)}</p>` : ''}
|
||
<div class="grid qgrid">${up.groups.map((g, i) => {
|
||
const cur = up.current?.mangaId === g.mangaId ? up.current : null
|
||
const rest = g.queued - (cur ? 1 : 0)
|
||
const next = g.next.length ? ` (sig.: ${g.next.map(n => esc(n ?? '?')).join(', ')}${rest > g.next.length ? '…' : ''})` : ''
|
||
return qcard({
|
||
key: `hd:${g.mangaId}`, id: g.mangaId, cover: g.cover, title: g.title,
|
||
pct: cur?.total ? (cur.pages / cur.total) * 100 : null,
|
||
badge: cur ? icon('sparkles') : g.queued ? i + 1 : '!',
|
||
lines: `${cur ? `<div>Cap. ${esc(cur.num ?? '?')} · pág. ${cur.pages}/${cur.total}</div>` : ''}
|
||
${g.queued ? `<div>${rest ? `${cur ? `${rest} más` : plural(rest, 'capítulo')} en cola${next}` : 'Último de la cola'}</div><div>${fmtDur(g.eta)}</div>` : ''}
|
||
${g.errors.length ? `<div class="error" title="${esc(g.errors.map(e => `Cap. ${e.num ?? '?'}: ${e.error || 'error'}`).join('\n'))}">${g.errors.length === 1 ? `Cap. ${esc(g.errors[0].num ?? '?')}: ${esc(g.errors[0].error || 'error')}` : `${g.errors.length} con error (caps. ${g.errors.slice(0, 6).map(e => esc(e.num ?? '?')).join(', ')}${g.errors.length > 6 ? '…' : ''})`}</div>` : ''}`,
|
||
actions: `${g.errors.length ? `<button class="btn small" data-retry="${esc(g.mangaId)}" data-ids="${esc(g.errors.map(e => e.chapterId).join(' '))}">${icon('refresh')} Reintentar</button>` : ''}
|
||
${g.queued ? `<button class="btn small" data-hdstop="${esc(g.mangaId)}" title="Vacía su cola y desactiva el reescalado automático">${icon('stop')} Detener</button>` : ''}`,
|
||
})
|
||
}).join('')}</div>` : up.available ? '<p class="muted">No hay capítulos esperando. Se reescalan desde la ficha de un manga descargado o desde el lector.</p>' : ''}
|
||
${extra.length ? `<div class="facts muted">${extra.join('')}</div>` : ''}
|
||
</section>
|
||
<section class="qsec">
|
||
<h2>${icon('book')} Biblioteca descargada</h2>
|
||
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||
${library.length ? `<div class="grid">${library.map(d => qcard({
|
||
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
|
||
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
|
||
lines: `<div>${esc(downloadLabel(d))}</div><div>${fmtSize(d.size)}</div>
|
||
${d.status === 'error' ? `<div class="error" title="${esc(d.error || '')}">Con errores</div>` : ''}`,
|
||
del: `<button class="hdel" data-del="${esc(d.manga_id)}" data-title="${esc(d.title)}" data-size="${d.size || 0}" ${d.shared ? 'data-shared="1"' : ''} title="${d.shared ? 'Quitar de tu biblioteca (otro usuario también lo tiene)' : `Eliminar la copia local (mangas/${esc(d.folder)})`}" aria-label="Eliminar la copia local">${icon('trash')}</button>`,
|
||
})).join('')}</div>` : `<p class="muted">${list.length ? 'Todo lo descargado está aún en la cola.' : 'Aún no tienes nada descargado. Usa “Descargar” en la ficha de un manga; lo que ya descargó otro usuario aparece aquí en cuanto lo empiezas a leer.'}</p>`}
|
||
</section>`
|
||
// Refresco sin parpadeo: se actualiza solo lo que cambia (las portadas no se vuelven a cargar).
|
||
let wrap = $('#qwrap')
|
||
if (!wrap) {
|
||
app.innerHTML = `<div id="qwrap">${html}</div>`
|
||
wrap = $('#qwrap')
|
||
// Delegación: los botones se crean y actualizan en cada refresco.
|
||
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg) } catch (err) { toast(err.message) } viewQueues() }
|
||
wrap.addEventListener('click', e => {
|
||
const b = e.target.closest('button')
|
||
if (!b) return
|
||
if (b.dataset.cancel) act(() => api(`/api/download/${b.dataset.cancel}/cancel`, { method: 'POST' }), 'Descarga cancelada')
|
||
else if (b.dataset.hdstop) act(() => api(`/api/upscale/${b.dataset.hdstop}/cancel`, { method: 'POST' }), 'Reescalado detenido')
|
||
else if (b.dataset.devcancel) { offCancel(b.dataset.devcancel); toast('Guardado cancelado') }
|
||
else if (b.dataset.retry) act(() => api(`/api/upscale/${b.dataset.retry}`, { method: 'POST', body: { chapterIds: b.dataset.ids.split(' ') } }), 'Vuelto a la cola')
|
||
else if (b.dataset.del) {
|
||
const shared = !!b.dataset.shared
|
||
if (!confirm(shared ? `¿Quitar «${b.dataset.title}» de tu biblioteca descargada?\nOtro usuario también lo tiene: los archivos se quedan.`
|
||
: `¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), shared ? 'Quitado de tu biblioteca' : 'Copia local eliminada')
|
||
}
|
||
})
|
||
} else {
|
||
const tpl = document.createElement('div')
|
||
tpl.innerHTML = html
|
||
morph(wrap, tpl, true)
|
||
}
|
||
// Refresco: rápido mientras algo avanza, lento si no (por si llega algo nuevo).
|
||
const busy = q.downloads.length || up.current || up.groups.some(g => g.queued) || q.clips.queued + q.clips.running || q.covers
|
||
const t = setTimeout(() => here() && viewQueues(), q.offline ? 10000 : busy ? 2000 : 15000)
|
||
// El guardado en el dispositivo avanza en el navegador: su sección se redibuja sola en cada página.
|
||
let raf = 0
|
||
const onDev = () => {
|
||
if (raf) return
|
||
raf = requestAnimationFrame(() => {
|
||
raf = 0
|
||
const box = $('#devq')
|
||
if (!box || !here()) return
|
||
const tpl = document.createElement('section')
|
||
tpl.innerHTML = devHtml()
|
||
morph(box, tpl, true)
|
||
})
|
||
}
|
||
offState.listeners.add(onDev)
|
||
cleanup = () => { clearTimeout(t); offState.listeners.delete(onDev); cancelAnimationFrame(raf) }
|
||
}
|
||
|
||
// ---------- Estadísticas ----------
|
||
const fmtNum = n => n.toLocaleString('es')
|
||
const fmtDay = d => new Date(`${d}T12:00:00`).toLocaleDateString('es', { day: 'numeric', month: 'short' })
|
||
const niceMax = v => { const p = 10 ** Math.floor(Math.log10(v)); return [1, 2, 2.5, 5, 10].map(m => m * p).find(m => m >= v) }
|
||
|
||
// Barras verticales de una sola serie; cada columna tiene una zona de hover más grande que la barra.
|
||
function barChart(data, { value, tip, label }, W = 560) {
|
||
const H = 190, L = 34, B = 24, T = 8
|
||
const max = niceMax(Math.max(1, ...data.map(value)))
|
||
const cw = (W - L) / data.length
|
||
const bw = Math.max(4, Math.min(28, cw - 6))
|
||
const y = v => T + (H - T - B) * (1 - v / max)
|
||
const ticks = Number.isInteger(max / 2) ? [0, max / 2, max] : [0, max]
|
||
const grid = ticks.map(v => `<line x1="${L}" x2="${W}" y1="${y(v)}" y2="${y(v)}" class="grid"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end" class="axis">${fmtNum(Math.round(v))}</text>`).join('')
|
||
const bars = data.map((d, i) => {
|
||
const v = value(d)
|
||
const x = L + i * cw + (cw - bw) / 2
|
||
const top = y(v), base = y(0), r = Math.min(4, bw / 2, base - top)
|
||
const bar = v > 0 ? `<path class="mark" d="M${x},${base}V${top + r}Q${x},${top} ${x + r},${top}H${x + bw - r}Q${x + bw},${top} ${x + bw},${top + r}V${base}Z"/>` : ''
|
||
const every = W < 420 ? 3 : 2
|
||
const lab = (data.length - 1 - i) % every ? '' : `<text x="${i === data.length - 1 ? x + bw : x + bw / 2}" y="${H - 6}" text-anchor="${i === data.length - 1 ? 'end' : 'middle'}" class="axis">${label(d)}</text>`
|
||
return `<g class="col" data-tip="${esc(tip(d))}"><rect x="${L + i * cw}" y="${T}" width="${cw}" height="${H - T - B}" class="hit"/>${bar}${lab}</g>`
|
||
}).join('')
|
||
return `<svg viewBox="0 0 ${W} ${H}" class="chart" role="img">${grid}${bars}</svg>`
|
||
}
|
||
|
||
async function viewStats() {
|
||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||
const st = await api('/api/stats')
|
||
const t = st.totals
|
||
if (!t.pages) {
|
||
app.innerHTML = '<h1>Estadísticas</h1><p class="muted">Aún no hay lecturas registradas. Lee algún capítulo y vuelve por aquí.</p>'
|
||
return
|
||
}
|
||
const maxDay = Math.max(1, ...st.calendar.map(d => d.pages))
|
||
const level = p => (p === 0 ? 0 : Math.min(5, Math.ceil((p / maxDay) * 5)))
|
||
const weekLabel = w => fmtDay(w.start)
|
||
const tile = (value, label, sub = '') => `<div class="tile"><div class="tv">${value}</div><div class="tl">${label}</div>${sub ? `<div class="ts">${sub}</div>` : ''}</div>`
|
||
// Calendario: columnas = semanas (lunes arriba).
|
||
const firstDow = (new Date(`${st.calendar[0].day}T12:00:00`).getDay() + 6) % 7
|
||
const cells = [...Array(firstDow).fill('<i class="cell empty"></i>'), ...st.calendar.map(d =>
|
||
`<i class="cell l${level(d.pages)}" data-tip="${esc(`${fmtDay(d.day)}: ${d.pages ? `${fmtNum(d.pages)} págs.` : 'sin lectura'}${d.chapters ? ` · ${d.chapters} caps.` : ''}`)}"></i>`)].join('')
|
||
const gmax = Math.max(1, ...st.genres.map(g => g.pages))
|
||
|
||
app.innerHTML = `<h1>${icon('chart')} Estadísticas</h1>
|
||
<div class="tiles">
|
||
${tile(`${st.streak.current} ${st.streak.current === 1 ? 'día' : 'días'}`, 'Racha actual', st.streak.current && !st.streak.readToday ? 'Lee hoy para mantenerla' : st.streak.readToday ? 'Hoy ya leíste ✓' : '')}
|
||
${tile(`${st.streak.best} ${st.streak.best === 1 ? 'día' : 'días'}`, 'Mejor racha')}
|
||
${tile(fmtNum(t.thisWeekChapters), 'Capítulos esta semana', `${fmtNum(t.thisWeekPages)} páginas`)}
|
||
${tile(fmtNum(t.chapters), 'Capítulos terminados')}
|
||
${tile(fmtNum(t.pages), 'Páginas leídas')}
|
||
${tile(fmtNum(t.mangas), 'Mangas', `${t.activeDays} ${t.activeDays === 1 ? 'día' : 'días'} con lectura`)}
|
||
</div>
|
||
<div class="charts">
|
||
<section class="panel"><h3>Capítulos terminados por semana</h3><div class="chart-slot" data-chart="chapters"></div></section>
|
||
<section class="panel"><h3>Páginas leídas por semana</h3><div class="chart-slot" data-chart="pages"></div></section>
|
||
</div>
|
||
<section class="panel"><h3>Actividad (últimas 18 semanas)</h3>
|
||
<div class="heat-wrap"><div class="heat">${cells}</div></div>
|
||
<div class="heat-legend muted">Menos <i class="cell l0"></i><i class="cell l1"></i><i class="cell l2"></i><i class="cell l3"></i><i class="cell l4"></i><i class="cell l5"></i> Más páginas</div>
|
||
</section>
|
||
<div class="charts">
|
||
<section class="panel"><h3>Géneros favoritos</h3>
|
||
${st.genres.length ? `<div class="hbars">${st.genres.map(g => `<div class="hbar" data-tip="${esc(`${g.name}: ${fmtNum(g.pages)} págs. (${g.pct}% de lo leído)`)}">
|
||
<span class="hl">${esc(g.name)}</span><span class="ht"><i style="width:${(g.pages / gmax) * 100}%"></i></span><span class="hv">${g.pct}%</span></div>`).join('')}</div>
|
||
<p class="muted" style="font-size:.8rem">Porcentaje de páginas leídas de mangas con ese género (un manga cuenta en todos sus géneros).</p>`
|
||
: '<p class="muted">Abre la ficha de los mangas que lees para registrar sus géneros.</p>'}
|
||
</section>
|
||
<section class="panel"><h3>Lo más leído</h3>
|
||
<div class="toplist">${st.topMangas.map((m, i) => `<a class="cont" href="#/manga/${m.id}"><span class="rank">${i + 1}</span>
|
||
<img src="${esc(m.cover || '/icon.svg')}" alt="" loading="lazy">
|
||
<div class="info"><b>${esc(m.title)}</b><div class="muted">${fmtNum(m.pages)} págs. · ${m.chapters} caps.</div></div></a>`).join('')}</div>
|
||
</section>
|
||
</div>
|
||
<details class="panel"><summary>Ver datos por semana</summary>
|
||
<table class="data"><thead><tr><th>Semana del</th><th>Capítulos</th><th>Páginas</th></tr></thead>
|
||
<tbody>${[...st.weeks].reverse().map(w => `<tr><td>${fmtDay(w.start)}</td><td>${w.chapters}</td><td>${fmtNum(w.pages)}</td></tr>`).join('')}</tbody></table>
|
||
</details>
|
||
<div id="tip" hidden></div>`
|
||
|
||
// Las gráficas se dibujan al ancho real del panel para que el texto no encoja en el móvil.
|
||
const charts = {
|
||
chapters: { value: w => w.chapters, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${w.chapters} caps.` },
|
||
pages: { value: w => w.pages, label: weekLabel, tip: w => `Semana del ${fmtDay(w.start)}: ${fmtNum(w.pages)} págs.` },
|
||
}
|
||
const drawCharts = () => app.querySelectorAll('.chart-slot').forEach(el => { el.innerHTML = barChart(st.weeks, charts[el.dataset.chart], Math.max(260, el.clientWidth)) })
|
||
drawCharts()
|
||
let rt = null
|
||
const onResize = () => { clearTimeout(rt); rt = setTimeout(drawCharts, 150) }
|
||
window.addEventListener('resize', onResize)
|
||
|
||
// Tooltip compartido para barras, celdas y géneros.
|
||
const tipEl = $('#tip')
|
||
const move = e => {
|
||
const target = e.target.closest('[data-tip]')
|
||
if (!target) { tipEl.hidden = true; return }
|
||
tipEl.textContent = target.dataset.tip
|
||
tipEl.hidden = false
|
||
const r = tipEl.getBoundingClientRect()
|
||
tipEl.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, e.clientX - r.width / 2))}px`
|
||
tipEl.style.top = `${Math.max(8, e.clientY - r.height - 12)}px`
|
||
}
|
||
app.addEventListener('pointermove', move)
|
||
app.addEventListener('pointerdown', move)
|
||
app.addEventListener('pointerleave', () => (tipEl.hidden = true))
|
||
cleanup = () => { app.removeEventListener('pointermove', move); app.removeEventListener('pointerdown', move); window.removeEventListener('resize', onResize) }
|
||
}
|
||
|
||
// ---------- Ajustes ----------
|
||
function viewSettings() {
|
||
app.innerHTML = `<h1>Ajustes</h1>
|
||
<div class="settings">
|
||
<section><b>Cuenta</b><div class="muted">Sesión iniciada como <b>${esc(me.username)}</b></div>
|
||
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
|
||
<section><b>Idioma preferido de los capítulos</b>
|
||
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||
<section><b>Cambiar contraseña</b>
|
||
<form id="pw" style="display:grid;gap:8px">
|
||
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
|
||
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="8">
|
||
<button class="btn primary">${icon('key')} Guardar</button></form></section>
|
||
<section><b>${icon('download')} Descarga anticipada</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).</p>
|
||
<div class="row" style="flex-wrap:wrap;gap:10px">
|
||
<select id="pfn">${[[0, 'Desactivada'], [1, 'El siguiente capítulo'], [2, '2 capítulos siguientes'], [3, '3 capítulos siguientes'], [5, '5 capítulos siguientes']].map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
|
||
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
|
||
</div></section>
|
||
<section><b>${icon('phone')} App y lectura sin conexión</b>
|
||
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («⋯ → En este dispositivo») o desde el lector.</p>
|
||
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
|
||
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
|
||
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
|
||
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
|
||
</section>
|
||
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
|
||
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
|
||
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
|
||
<form id="notify" style="display:grid;gap:8px">
|
||
<b style="font-size:.9rem">ntfy</b>
|
||
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off">
|
||
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
|
||
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
|
||
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
|
||
<input type="password" name="tgToken" placeholder="Token del bot (de @BotFather)" autocomplete="new-password">
|
||
<input type="text" name="tgChat" placeholder="Chat ID" autocomplete="off">
|
||
<p class="muted" style="margin:0;font-size:.8rem">Los tokens guardados no se muestran: deja el campo vacío para conservarlo o escribe «-» para borrarlo.</p>
|
||
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
|
||
</form></section>
|
||
</div>`
|
||
api('/api/notify').then(n => {
|
||
const f = $('#notify')
|
||
if (!f) return
|
||
f.ntfyUrl.value = n.ntfyUrl
|
||
f.ntfyTopic.value = n.ntfyTopic
|
||
f.tgChat.value = n.tgChat
|
||
if (n.hasNtfyToken) f.ntfyToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
|
||
if (n.hasTgToken) f.tgToken.placeholder = 'Token guardado ✓ (vacío = conservar, «-» = borrar)'
|
||
}).catch(() => {})
|
||
$('#notify').addEventListener('submit', async e => {
|
||
e.preventDefault()
|
||
const f = e.target
|
||
try {
|
||
await api('/api/notify', { method: 'POST', body: Object.fromEntries(['ntfyUrl', 'ntfyTopic', 'ntfyToken', 'tgToken', 'tgChat'].map(k => [k, f[k].value])) })
|
||
toast('Avisos guardados')
|
||
viewSettings()
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
const renderBans = async () => {
|
||
const b = await api('/api/bans').catch(() => null)
|
||
const box = $('#bans')
|
||
if (!box || !b) return
|
||
box.innerHTML = `<p style="margin:0">Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.</p>
|
||
${b.bans.length ? `<div class="banlist">${b.bans.map(x => `<div class="row"><code>${esc(x.ip)}</code><span class="muted small" style="flex:1">hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')}</span>
|
||
<button class="btn small" data-unban="${esc(x.ip)}">Desbloquear</button></div>`).join('')}</div>` : '<p style="margin:6px 0 0">Ninguna IP bloqueada.</p>'}
|
||
<p class="muted small" style="margin:6px 0 0">Tu IP vista por el servidor: <code>${esc(b.you.ip)}</code>${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de <code>${esc(b.trustedHeader)}</code>` : ''}</p>
|
||
${b.fails.length ? `<p style="margin:6px 0 0">Intentos fallidos recientes: ${b.fails.map(f => `<code>${esc(f.ip)}</code> ×${f.n}`).join(', ')}</p>` : ''}`
|
||
box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => {
|
||
try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) }
|
||
}))
|
||
}
|
||
renderBans()
|
||
api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {})
|
||
const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message))
|
||
$('#pfn').addEventListener('change', savePf)
|
||
$('#pfhd').addEventListener('change', savePf)
|
||
if (OFFLINE_OK) {
|
||
const dev = offByManga()
|
||
$('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno'
|
||
$('#offclear').hidden = !dev.length
|
||
$('#offclear').addEventListener('click', async () => {
|
||
if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return
|
||
await caches.delete(OFF_CACHE)
|
||
store.set('offlineChapters', {})
|
||
toast('Borrado')
|
||
viewSettings()
|
||
})
|
||
const inst = $('#install')
|
||
inst.hidden = !installPrompt
|
||
inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true })
|
||
}
|
||
$('#ntest').addEventListener('click', async () => {
|
||
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
|
||
})
|
||
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
|
||
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
|
||
$('#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) }
|
||
})
|
||
}
|