- Estante: entradas desde el menú, la librería y la ficha; orden reciente o A–Z; foto del estante, arte de lomos y portadas guardados en el dispositivo; sin conexión muestra solo lo guardado. - Notas de 40 letras sin cortar palabras, lomo curvo, sombra en las portadas de frente y lomos con logo sin el título. - Pósit cuadrado con el capítulo y la página por la que va el usuario; paso al lector sin pantalla de carga. - Giroscopio: integra rotationRate (sin los saltos de β/γ con el dispositivo vertical), tope suave, menos sensibilidad al caminar, lado en horizontal según la gravedad, suavizado por tiempo y más rango en el estante y el libro. - Laboratorio del giroscopio en Ajustes (temporal, solo desarrollo) para grabar los sensores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4608 lines
264 KiB
JavaScript
4608 lines
264 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"/>',
|
||
shelf: '<path d="M3 3v18M21 3v18M3 12h18M3 21h18"/><path d="M6 12V6M9 12V5M12.5 12l1.6-6"/><path d="M6 21v-5M9 21v-6M15 21v-6M18 21v-4"/>',
|
||
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)', gb: 'Project Gutenberg (libros)', ws: 'Wikisource (libros en español)', ub: 'Mis libros (EPUB subidos)' }
|
||
const NO_CATS = new Set(['mi', 'gb', 'ws', 'ub']) // fuentes sin listado por categorías
|
||
const BOOK_SOURCES = new Set(['gb', 'ws', 'ub'])
|
||
const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
|
||
// Ajuste del usuario: ver solo manga, solo libros o ambos. Solo filtra lo que se ofrece; un enlace directo sigue abriendo.
|
||
const contentMode = () => me?.content_mode || 'both'
|
||
const wantsId = id => contentMode() === 'both' || (contentMode() === 'books') === isBookId(id)
|
||
const visibleSources = () => Object.entries(SOURCES).filter(([k]) => contentMode() === 'both' || (contentMode() === 'books') === BOOK_SOURCES.has(k))
|
||
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
|
||
const FILTERS = {
|
||
none: ['Sin filtro', ''],
|
||
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()
|
||
if (data.text) {
|
||
// Libro: lo que se guarda es su texto y sus imágenes; la lista queda en la copia, para poder borrarlos después.
|
||
const textUrl = `/api/book/${it.mangaId}/text`
|
||
const tres = await fetch(textUrl, { signal })
|
||
if (!tres.ok) throw new Error(`Error ${tres.status}`)
|
||
const text = await tres.json()
|
||
const images = new Set(text.chapters.flatMap(c => c.blocks).filter(b => b.k === 'img').map(b => `/api/book/${it.mangaId}/img/${b.n}`))
|
||
data.pages = [textUrl, ...images]
|
||
await cache.put(chapUrl, new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }))
|
||
} else 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
|
||
let shelfTrip = null // { mangaId } mientras se lee algo abierto desde el estante: libro 3D y vuelta al estante
|
||
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 || '')
|
||
if (parts[0] !== 'read' && parts[0] !== 'shelf') shelfTrip = null
|
||
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] === 'shelf') return await viewShelf(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()
|
||
if (parts[0] === 'gyrolab') return await viewGyroLab() // solo desarrollo
|
||
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) {
|
||
fol = { ...fol, follows: fol.follows.filter(f => wantsId(f.manga_id)) }
|
||
const fresh = fol.follows.filter(f => f.newCount > 0)
|
||
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
|
||
if (!fol.follows.length) return ''
|
||
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 [all, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||
const list = all.filter(m => wantsId(m.id))
|
||
const have = new Set(list.map(m => m.id))
|
||
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
|
||
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||
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 shownSources = visibleSources()
|
||
const isShown = k => shownSources.some(([s]) => s === k)
|
||
const source = isShown(params.get('source')) ? params.get('source') : isShown(store.get('source', 'lc')) ? store.get('source', 'lc') : shownSources[0][0]
|
||
let mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
|
||
store.set('source', source)
|
||
const showCats = mode === 'cats' && !tag
|
||
app.innerHTML = `
|
||
<section id="cont"></section>
|
||
<section id="recs"></section>
|
||
<div class="tabs" id="sources">${shownSources.map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
|
||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||
${source === 'ub' ? `<div class="row" style="margin:10px 0;flex-wrap:wrap;gap:10px"><label class="btn primary" id="ubpick">${icon('plus')} Subir EPUB<input type="file" id="ubfile" accept=".epub,application/epub+zip" multiple hidden></label><span class="muted" style="font-size:.85rem">Solo tú puedes verlos. Máximo 100 MB por libro.</span></div>` : ''}
|
||
${source === 'ub' ? (q ? `<h2>Resultados para “${esc(q)}”</h2>` : '') : 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')} ${BOOK_SOURCES.has(source) ? 'Añadidos recientemente' : 'Actualizados recientemente'}</button>
|
||
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
|
||
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
|
||
<div id="results" class="loading">Cargando…</div>
|
||
<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 && wantsId(h.manga_id)), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||
bindNews(fol)
|
||
}).catch(() => {})
|
||
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
|
||
}
|
||
|
||
$('#ubfile')?.addEventListener('change', async e => {
|
||
const files = [...e.target.files]
|
||
const label = $('#ubpick')
|
||
label.classList.add('disabled')
|
||
let added = 0
|
||
for (const [i, f] of files.entries()) {
|
||
toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`)
|
||
try {
|
||
const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f })
|
||
const d = await r.json().catch(() => ({}))
|
||
if (!r.ok) throw new Error(d.error || `Error ${r.status}`)
|
||
added++
|
||
} catch (err) { toast(`${f.name}: ${err.message}`) }
|
||
}
|
||
label.classList.remove('disabled')
|
||
if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() }
|
||
})
|
||
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' || source === 'gb' || source === 'ws' || !!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 ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : '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, hideCredits: creditsOn, 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 isBook = isBookId(id)
|
||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
|
||
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
|
||
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' || isBook ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||
manga.source === 'mi' || isBook ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||
manga.source === 'ub' ? `<button id="ubdel" class="btn danger">${icon('trash')} Eliminar libro</button>` : '',
|
||
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>` : ''}
|
||
${SHELF_OK && !isBook && !/^(manhwa|manhua|webtoon)$/i.test(manga.type || '') ? `<a class="btn" id="toshelf" href="#/shelf?focus=${encodeURIComponent(id)}" title="Ver la obra en el estante 3D">${icon('shelf')} Estante</a>` : ''}
|
||
<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>
|
||
<button class="btn" id="hidecred" hidden></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" ${isBook && !OFFLINE_OK ? 'style="display:none"' : ''}><div class="psec" id="dl" ${isBook ? 'style="display:none"' : ''}></div><div class="psec" id="hd" hidden ${isBook ? 'style="display:none"' : ''}></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 && !isBook) {
|
||
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
|
||
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
|
||
}
|
||
|
||
// --- 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
|
||
if (isBook) {
|
||
// Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes.
|
||
const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : ''
|
||
const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.'
|
||
box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}<div class="note muted">${note}</div><div class="psec-actions">
|
||
${mine.length ? `<button class="btn small danger" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : run || queued ? '' : `<button class="btn small" id="offbook">${icon('phone')} Guardar libro</button>`}</div>`
|
||
$('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }]))
|
||
$('#offdel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return
|
||
await offRemove(mine.map(([cid]) => cid))
|
||
toast('Borrado del dispositivo')
|
||
})
|
||
return
|
||
}
|
||
// 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 || isBook
|
||
}
|
||
renderFollow()
|
||
// Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre.
|
||
let hideCredits = !!creditsOn
|
||
function renderCredits() {
|
||
const b = $('#hidecred')
|
||
if (!b) return
|
||
b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos`
|
||
b.classList.toggle('on', hideCredits)
|
||
b.setAttribute('aria-pressed', hideCredits)
|
||
b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.'
|
||
b.hidden = offline || !vols.length
|
||
}
|
||
$('#hidecred').addEventListener('click', async () => {
|
||
const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } })
|
||
hideCredits = r.hideCredits
|
||
renderCredits()
|
||
toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas')
|
||
})
|
||
$('#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')
|
||
})
|
||
$('#ubdel')?.addEventListener('click', async () => {
|
||
if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return
|
||
try {
|
||
await api(`/api/books/${id}`, { method: 'DELETE' })
|
||
if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid))
|
||
toast('Libro eliminado')
|
||
location.hash = '#/?source=ub'
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
$('#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>`
|
||
}
|
||
// Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella.
|
||
let vols = [], volOf = new Map(), folded = new Set()
|
||
if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => {
|
||
if (!r.volumes?.length || !ul?.isConnected) return
|
||
vols = r.volumes
|
||
volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v])))
|
||
const open = volOf.get(progress?.chapter_id) || vols[0]
|
||
folded = new Set(vols.filter(v => v !== open).map(v => v.vol))
|
||
renderList()
|
||
renderCredits()
|
||
}, () => {})
|
||
const volHead = v => {
|
||
if (!v) return '<li class="volhead"><div class="voltoggle static"><b>Sin tomo</b></div></li>'
|
||
const inVol = chapters.filter(c => volOf.get(c.id) === v)
|
||
const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 }
|
||
const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id)
|
||
return `<li class="volhead"><button class="voltoggle" data-vol="${esc(v.vol)}" aria-expanded="${!folded.has(v.vol)}">
|
||
${v.thumb ? `<img src="${esc(v.thumb)}" alt="" loading="lazy">` : ''}<span><b>Tomo ${esc(v.vol)}</b><span class="meta">${plural(inVol.length, 'capítulo')}${read ? ` · ${read} leídos` : ''}</span></span>
|
||
</button><a class="btn small" href="${readUrl(id, from.id, r && !isDone(from) ? r.page : 0)}">${icon('book')} Leer tomo</a></li>`
|
||
}
|
||
ul?.addEventListener('click', e => {
|
||
const b = e.target.closest('button.voltoggle')
|
||
if (!b) return
|
||
folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol)
|
||
renderList()
|
||
})
|
||
function renderList() {
|
||
if (!ul) return
|
||
const list = desc ? [...chapters].reverse() : chapters
|
||
const picking = sel?.mode === 'pick'
|
||
pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
|
||
ul.classList.toggle('picking', picking)
|
||
// Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera.
|
||
const seen = new Set()
|
||
const grouped = !vols.length ? list : list.flatMap(c => {
|
||
const v = volOf.get(c.id)
|
||
if (!v) return [c]
|
||
if (seen.has(v)) return []
|
||
seen.add(v)
|
||
return list.filter(x => volOf.get(x.id) === v)
|
||
})
|
||
let curVol
|
||
ul.innerHTML = grouped.map((c, i) => {
|
||
const v = volOf.get(c.id) || null
|
||
const head = vols.length && v !== curVol ? volHead(v) : ''
|
||
curVol = v
|
||
if (v && !picking && folded.has(v.vol)) return head
|
||
return head + chapterItem(c, i)
|
||
}).join('')
|
||
}
|
||
let pickable = new Set()
|
||
function chapterItem(c, i) {
|
||
const picking = sel?.mode === 'pick'
|
||
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>`
|
||
}
|
||
// 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()
|
||
if (!isBook) 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 B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window
|
||
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 (manga.type === 'Libro') return { mode: 'paged', fit: 'height', dir: 'ltr' }
|
||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
|
||
const legacy = store.get('readerSettings', {})
|
||
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
|
||
}
|
||
|
||
// ---------- Libros: el libro entero se maqueta como un texto continuo (cada capítulo empieza justo debajo del
|
||
// anterior) en páginas del tamaño de la pantalla, y cada página se dibuja en un canvas. Al lector le llega como
|
||
// una imagen más, así que hoja, sombra, hendidura y sonido no cambian.
|
||
const BOOK_DEFAULTS = { size: 100, font: 'serif', margin: 'mid', paper: 'light' }
|
||
const BOOK_FONTS = { serif: "Georgia, 'Iowan Old Style', 'Noto Serif', 'Times New Roman', serif", sans: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
|
||
const BOOK_PAPERS = { light: { bg: '#fbf9f3', fg: '#22201c', dim: '#8b867b' }, sepia: { bg: '#f3e9d3', fg: '#3a2f21', dim: '#8f7f66' }, dark: { bg: '#1b1a18', fg: '#d8d4ca', dim: '#7c786f' } }
|
||
const BOOK_MARGINS = { small: 0.055, mid: 0.085, large: 0.12 }
|
||
const BOOK_KEEP_PAGES = 16 // páginas dibujadas que se conservan a cada lado de la actual
|
||
const bookSettings = () => ({ ...BOOK_DEFAULTS, ...store.get('bookSettings', {}) })
|
||
let bookResume = null // al volver a maquetar (rotar, cambiar la letra): seguir en la misma palabra
|
||
let bookCache = null // { id, text } el último libro, para no pedirlo otra vez al reabrir
|
||
let bookLayoutCache = null // { key, layout } y su maquetado, para esta pantalla y esta letra
|
||
|
||
// El libro como una sola lista de bloques. Cada capítulo lleva su división y su título, aunque el texto no lo traiga.
|
||
function bookFlatten(chapters) {
|
||
const flat = [], chapBlock = []
|
||
chapters.forEach((c, i) => {
|
||
chapBlock.push(flat.length)
|
||
if (i > 0) flat.push({ k: 'div' })
|
||
if (c.blocks[0]?.k !== 'h' && !/^Sección \d+$/.test(c.title)) flat.push({ k: 'h', l: 2, r: [[c.title, 0]] })
|
||
for (const b of c.blocks) flat.push(b)
|
||
})
|
||
return { flat, chapBlock }
|
||
}
|
||
|
||
// Una primera pasada solo busca dónde empieza cada página; lo que se dibuja se calcula al pedir esa página. Así un
|
||
// libro de miles de páginas no ocupa memoria con la posición de cada palabra.
|
||
function makeBookLayout(text, W, H, opts) {
|
||
const { flat, chapBlock } = bookFlatten(text.chapters)
|
||
const measure = document.createElement('canvas').getContext('2d')
|
||
const family = BOOK_FONTS[opts.font] || BOOK_FONTS.serif
|
||
const px = Math.min(22, Math.max(15, W / 24)) * (opts.size / 100)
|
||
const lineH = Math.round(px * 1.45)
|
||
const marginX = Math.round(W * (BOOK_MARGINS[opts.margin] || BOOK_MARGINS.mid))
|
||
const top = Math.round(Math.max(px * 2.6, 30)), bottom = Math.round(H - Math.max(px * 2.6, 30))
|
||
const cw = W - marginX * 2
|
||
const widths = new Map()
|
||
const fontOf = (size, style, forceBold) => `${style & 1 ? 'italic ' : ''}${style & 2 || forceBold ? 'bold ' : ''}${size}px ${family}`
|
||
const widthOf = (t, font) => {
|
||
const key = `${font}|${t}`
|
||
let w = widths.get(key)
|
||
if (w === undefined) {
|
||
if (measure.font !== font) measure.font = font
|
||
w = measure.measureText(t).width
|
||
widths.set(key, w)
|
||
}
|
||
return w
|
||
}
|
||
// Un bloque en líneas: cada línea son cajas (palabras, que pueden cambiar de estilo por dentro).
|
||
function breakLines(runs, width, indent, size, forceBold) {
|
||
const spaceW = widthOf(' ', fontOf(size, 0, forceBold))
|
||
const lines = []
|
||
let line = { boxes: [], w: 0, hard: false }
|
||
let box = null, space = false
|
||
const room = () => width - (lines.length ? 0 : indent)
|
||
const endLine = hard => { line.hard = hard; lines.push(line); line = { boxes: [], w: 0, hard: false } }
|
||
const place = b => {
|
||
if (line.boxes.length && line.w + spaceW + b.w > room()) endLine(false)
|
||
line.w += (line.boxes.length ? spaceW : 0) + b.w
|
||
line.boxes.push(b)
|
||
}
|
||
const flushBox = () => {
|
||
if (!box) return
|
||
// Una palabra más ancha que la línea se corta por letras.
|
||
if (box.w > room() && box.frags.length === 1) {
|
||
const { font } = box.frags[0]
|
||
let cur = ''
|
||
for (const ch of Array.from(box.frags[0].t)) {
|
||
if (cur && widthOf(cur + ch, font) > width) {
|
||
place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
|
||
endLine(false)
|
||
cur = ''
|
||
}
|
||
cur += ch
|
||
}
|
||
if (cur) place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
|
||
} else place(box)
|
||
box = null
|
||
}
|
||
for (const [t0, style] of runs) {
|
||
const font = fontOf(size, style, forceBold)
|
||
for (const m of t0.matchAll(/\n| +|[^ \n]+/g)) {
|
||
const t = m[0]
|
||
if (t === '\n') { flushBox(); space = false; endLine(true) }
|
||
else if (t[0] === ' ') { flushBox(); space = true }
|
||
else {
|
||
if (space) { flushBox(); space = false }
|
||
const w = widthOf(t, font)
|
||
box ??= { frags: [], w: 0 }
|
||
box.frags.push({ t, font, w })
|
||
box.w += w
|
||
}
|
||
}
|
||
}
|
||
flushBox()
|
||
if (line.boxes.length) endLine(true)
|
||
return { lines, spaceW }
|
||
}
|
||
const headSize = b => Math.round(px * ({ 1: 1.5, 2: 1.35, 3: 1.2 }[b.l] || 1.08))
|
||
const headLines = b => { const size = headSize(b), height = Math.round(size * 1.3); return { ...breakLines(b.r, cw, 0, size, true), height } }
|
||
|
||
const pages = [{ block: 0, line: 0, word: 0, after: true }]
|
||
const chapStarts = [0]
|
||
// Recorre el libro desde el inicio de una página. Sin items, anota dónde empieza cada página; con items, recoge lo
|
||
// que hay que dibujar en esa página y termina.
|
||
function* walk(cp, items) {
|
||
let y = top, used = false, words = cp.word, after = cp.after
|
||
const newPage = (block, line) => {
|
||
if (items) return true
|
||
pages.push({ block, line, word: words, after })
|
||
y = top
|
||
used = false
|
||
return false
|
||
}
|
||
const remaining = () => bottom - y
|
||
const placeLine = (l, { x, width, indent, center, justify, spaceW, height }) => {
|
||
if (items) {
|
||
const avail = width - (center ? 0 : indent)
|
||
let gap = spaceW
|
||
if (justify && !l.hard && l.boxes.length > 1) {
|
||
const stretched = spaceW + (avail - l.w) / (l.boxes.length - 1)
|
||
if (stretched < spaceW + px * 1.2) gap = stretched // una línea muy suelta se queda sin justificar
|
||
}
|
||
let cx = center ? x + (width - l.w) / 2 : x + indent
|
||
const segs = []
|
||
for (const b of l.boxes) {
|
||
for (const f of b.frags) { segs.push({ t: f.t, font: f.font, x: cx }); cx += f.w }
|
||
cx += gap
|
||
}
|
||
items.push({ line: segs, y: y + height * 0.78 })
|
||
}
|
||
y += height
|
||
words += l.boxes.length
|
||
used = true
|
||
}
|
||
for (let b = cp.block; b < flat.length; b++) {
|
||
if (!items && !(b & 127)) yield
|
||
const block = flat[b]
|
||
const from = b === cp.block ? cp.line : 0
|
||
if (block.k === 'div') {
|
||
// División de capítulo: el capítulo sigue en la misma página si caben la línea, su título y algo de texto.
|
||
const nx = flat[b + 1]
|
||
let need = lineH * 5
|
||
if (nx?.k === 'h') { const h = headLines(nx); need += h.lines.length * h.height }
|
||
if (used && remaining() < need && newPage(b, 0)) return
|
||
if (!items) chapStarts.push(pages.length - 1)
|
||
if (used) {
|
||
y += lineH * 1.2
|
||
items?.push({ rule: true, y })
|
||
y += lineH * 0.6
|
||
}
|
||
after = true
|
||
continue
|
||
}
|
||
if (block.k === 'hr') {
|
||
if (remaining() < lineH * 3 && used && newPage(b, 0)) return
|
||
y += lineH * 0.7
|
||
items?.push({ rule: true, y })
|
||
y += lineH * 0.8
|
||
used = true
|
||
after = true
|
||
continue
|
||
}
|
||
if (block.k === 'img') {
|
||
if (!block.w || !block.h) continue
|
||
const fit = Math.min(cw / block.w, (bottom - top) / block.h, 2)
|
||
const w = block.w * fit, h = block.h * fit
|
||
if (h > remaining() && used && newPage(b, 0)) return
|
||
items?.push({ n: block.n, x: (W - w) / 2, y, w, h })
|
||
y += h + lineH * 0.5
|
||
used = true
|
||
after = true
|
||
continue
|
||
}
|
||
if (block.k === 'h') {
|
||
const { lines, spaceW, height } = headLines(block)
|
||
if (!from) {
|
||
const need = lines.length * height + lineH * 3 + (used ? lineH * 1.2 : 0)
|
||
if (used && remaining() < need && newPage(b, 0)) return // el título no se queda solo al pie
|
||
if (used) y += lineH * 1.2
|
||
}
|
||
for (let i = from; i < lines.length; i++) {
|
||
if (remaining() < height && used && newPage(b, i)) return
|
||
placeLine(lines[i], { x: marginX, width: cw, indent: 0, center: true, justify: false, spaceW, height })
|
||
}
|
||
y += lineH * 0.8
|
||
after = true
|
||
continue
|
||
}
|
||
const quote = block.k === 'q'
|
||
const verse = !!block.v
|
||
const center = !!block.c
|
||
const inset = quote ? Math.round(px * 1.4) : 0
|
||
const width = cw - inset * 2
|
||
const indent = !after && !quote && !verse && !center ? Math.round(px * 1.3) : 0
|
||
const { lines, spaceW } = breakLines(block.r, width, indent, px, false)
|
||
if (quote && used && !from) y += lineH * 0.3
|
||
for (let i = from; i < lines.length;) {
|
||
const fits = Math.floor(remaining() / lineH)
|
||
let k = Math.min(fits, lines.length - i)
|
||
if (!verse) {
|
||
// Ni una primera línea sola al pie ni una última línea sola arriba de la página siguiente.
|
||
if (i === 0 && k === 1 && lines.length > 1) k = 0
|
||
else if (i + k === lines.length - 1) k = k >= 2 ? k - 1 : 0
|
||
}
|
||
if (k <= 0) {
|
||
if (!used) k = Math.max(1, Math.min(fits, lines.length - i)) // no cabe ni en una página vacía: no dar vueltas
|
||
else { if (newPage(b, i)) return; continue }
|
||
}
|
||
for (let n = 0; n < k; n++) placeLine(lines[i + n], { x: marginX + inset, width, indent: i + n === 0 ? indent : 0, center, justify: !verse && !center, spaceW, height: lineH })
|
||
i += k
|
||
if (i < lines.length && newPage(b, i)) return
|
||
}
|
||
if (quote) y += lineH * 0.3
|
||
after = quote
|
||
}
|
||
}
|
||
return {
|
||
pages, chapStarts, chapBlock, flat, top, cw, family, footPx: Math.max(11, Math.round(px * 0.68)),
|
||
// Reparte el libro cediendo el paso de vez en cuando, para que la pantalla no se quede congelada.
|
||
async build() {
|
||
let t = performance.now()
|
||
for (const _ of walk(pages[0], null)) {
|
||
if (performance.now() - t > 40) { await new Promise(r => setTimeout(r)); t = performance.now() }
|
||
}
|
||
},
|
||
capture(i) {
|
||
const items = []
|
||
const run = walk(pages[i], items)
|
||
while (!run.next().done);
|
||
return items
|
||
},
|
||
}
|
||
}
|
||
|
||
async function loadBookText(mangaId) {
|
||
if (bookCache?.id === mangaId) return bookCache.text
|
||
const text = await api(`/api/book/${mangaId}/text`)
|
||
bookCache = { id: mangaId, text }
|
||
return text
|
||
}
|
||
|
||
async function createBook(text, mangaId, size) {
|
||
const opts = bookSettings()
|
||
const colors = BOOK_PAPERS[opts.paper] || BOOK_PAPERS.light
|
||
const { w: W, h: H } = size
|
||
const key = JSON.stringify([mangaId, W, H, opts])
|
||
let layout = bookLayoutCache?.key === key ? bookLayoutCache.layout : null
|
||
if (!layout) {
|
||
layout = makeBookLayout(text, W, H, opts)
|
||
await layout.build()
|
||
bookLayoutCache = { key, layout }
|
||
}
|
||
const { pages, chapStarts, chapBlock, flat } = layout
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 3, 4096 / Math.max(W, H))
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = Math.round(W * dpr)
|
||
canvas.height = Math.round(H * dpr)
|
||
const ctx = canvas.getContext('2d', { alpha: false })
|
||
const urls = new Map()
|
||
const waiting = new Map()
|
||
const queue = []
|
||
const images = new Map() // n -> { im, ready }
|
||
let released = false, timer = 0
|
||
const imageOf = n => {
|
||
let e = images.get(n)
|
||
if (!e) {
|
||
const im = new Image()
|
||
e = { im, ok: false, ready: new Promise(r => { im.onload = () => { e.ok = im.naturalWidth > 0; e.failed = !e.ok; r() }; im.onerror = () => { e.failed = true; r() } }) }
|
||
images.set(n, e)
|
||
im.src = `/api/book/${mangaId}/img/${n}`
|
||
}
|
||
return e
|
||
}
|
||
const chapterOfBlock = block => {
|
||
let lo = 0, hi = chapBlock.length - 1
|
||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapBlock[mid] <= block) lo = mid; else hi = mid - 1 }
|
||
return lo
|
||
}
|
||
function draw(i, items) {
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||
ctx.fillStyle = colors.bg
|
||
ctx.fillRect(0, 0, W, H)
|
||
ctx.textBaseline = 'alphabetic'
|
||
ctx.textAlign = 'left'
|
||
ctx.fillStyle = colors.fg
|
||
let font = ''
|
||
for (const it of items) {
|
||
if (it.line) {
|
||
for (const s of it.line) {
|
||
if (s.font !== font) { ctx.font = font = s.font }
|
||
ctx.fillText(s.t, s.x, it.y)
|
||
}
|
||
} else if (it.rule) {
|
||
ctx.fillStyle = colors.dim
|
||
ctx.fillRect(W / 2 - W * 0.08, it.y, W * 0.16, 1)
|
||
ctx.fillStyle = colors.fg
|
||
} else {
|
||
const e = imageOf(it.n)
|
||
if (e.ok) ctx.drawImage(e.im, it.x, it.y, it.w, it.h)
|
||
}
|
||
}
|
||
// Título del capítulo arriba (salvo donde empieza) y número abajo: guías de lectura, no parte del texto.
|
||
ctx.fillStyle = colors.dim
|
||
ctx.textAlign = 'center'
|
||
ctx.font = `${layout.footPx}px ${layout.family}`
|
||
const p = pages[i]
|
||
const c = chapterOfBlock(p.block)
|
||
const title = text.chapters[c].title
|
||
const atStart = p.line === 0 && (p.block === chapBlock[c] || (p.block === chapBlock[c] + 1 && flat[p.block].k === 'h'))
|
||
if (!atStart && title && !/^Sección \d+$/.test(title)) {
|
||
let t = title
|
||
if (ctx.measureText(t).width > layout.cw) {
|
||
while (t && ctx.measureText(`${t}…`).width > layout.cw) t = t.slice(0, -1)
|
||
t += '…'
|
||
}
|
||
ctx.fillText(t, W / 2, layout.top - layout.footPx * 1.2)
|
||
}
|
||
ctx.fillText(`${i + 1} / ${pages.length}`, W / 2, H - layout.footPx * 1.4)
|
||
}
|
||
const pendingImages = items => items.filter(it => it.n != null && !images.get(it.n)?.ok && !images.get(it.n)?.failed).map(it => imageOf(it.n).ready)
|
||
const settle = (i, url) => {
|
||
if (!urls.has(i)) urls.set(i, url)
|
||
for (const cb of waiting.get(i) || []) cb(urls.get(i))
|
||
waiting.delete(i)
|
||
}
|
||
const forget = i => {
|
||
const u = urls.get(i)
|
||
if (u?.startsWith('blob:')) URL.revokeObjectURL(u)
|
||
urls.delete(i)
|
||
}
|
||
async function tick() {
|
||
timer = 0
|
||
if (released || !queue.length) return
|
||
if (book.busy?.()) { timer = setTimeout(tick, 150); return } // no dibujar mientras gira la hoja
|
||
const i = queue.shift()
|
||
if (urls.has(i)) { settle(i, urls.get(i)); timer = setTimeout(tick); return }
|
||
const items = layout.capture(i)
|
||
timer = -1 // ocupado: que nadie arranque otra vuelta mientras llegan las imágenes
|
||
await Promise.all(pendingImages(items))
|
||
if (released) return
|
||
draw(i, items)
|
||
canvas.toBlob(blob => {
|
||
if (released) return
|
||
if (blob && !urls.has(i)) settle(i, URL.createObjectURL(blob))
|
||
else settle(i, urls.get(i) ?? book.src(i))
|
||
timer = setTimeout(tick, 16)
|
||
}, 'image/png')
|
||
}
|
||
const book = {
|
||
count: pages.length, W, H, busy: null, onChange: null,
|
||
// Síncrono: la página hace falta ya (la URL no cambia una vez entregada; identifica su textura). Si aún no han
|
||
// llegado sus imágenes, se dibuja sin ellas y se avisa para cambiarla al llegar.
|
||
src(i) {
|
||
if (urls.has(i)) return urls.get(i)
|
||
const items = layout.capture(i)
|
||
const pending = pendingImages(items)
|
||
draw(i, items)
|
||
urls.set(i, canvas.toDataURL('image/png'))
|
||
if (waiting.has(i)) settle(i, urls.get(i))
|
||
if (pending.length) {
|
||
Promise.all(pending).then(() => {
|
||
if (released || !urls.has(i)) return
|
||
urls.delete(i)
|
||
book.onChange?.(i)
|
||
})
|
||
}
|
||
return urls.get(i)
|
||
},
|
||
// Asíncrono: se dibuja cuando haya un momento libre, en el orden en que se pidió.
|
||
whenReady(i, cb) {
|
||
if (urls.has(i)) return cb(urls.get(i))
|
||
if (!waiting.has(i)) { waiting.set(i, []); queue.push(i) }
|
||
waiting.get(i).push(cb)
|
||
if (!timer) timer = setTimeout(tick)
|
||
},
|
||
// Lo dibujado lejos de la página actual se suelta; si se vuelve, se dibuja otra vez.
|
||
trim(center) {
|
||
for (const i of [...urls.keys()]) if (Math.abs(i - center) > BOOK_KEEP_PAGES) forget(i)
|
||
},
|
||
near: (i, center) => Math.abs(i - center) <= BOOK_KEEP_PAGES,
|
||
wordAt: i => pages[Math.min(i, pages.length - 1)]?.word ?? 0,
|
||
pageAtWord(word) {
|
||
let lo = 0, hi = pages.length - 1
|
||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pages[mid].word <= word) lo = mid; else hi = mid - 1 }
|
||
return lo
|
||
},
|
||
chapterStart: c => chapStarts[c] ?? 0,
|
||
// Si varios capítulos empiezan en una página, cuenta el último: ahí es donde sigue la lectura.
|
||
chapterAt(p) {
|
||
let lo = 0, hi = chapStarts.length - 1
|
||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapStarts[mid] <= p) lo = mid; else hi = mid - 1 }
|
||
return lo
|
||
},
|
||
chapterPages: c => Math.max(1, (chapStarts[c + 1] ?? pages.length) - chapStarts[c]),
|
||
release() {
|
||
released = true
|
||
clearTimeout(timer)
|
||
for (const i of [...urls.keys()]) forget(i)
|
||
canvas.width = canvas.height = 0
|
||
},
|
||
}
|
||
return book
|
||
}
|
||
|
||
// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción.
|
||
async function pageSig(url) {
|
||
const res = await fetch(url)
|
||
if (!res.ok) throw new Error('Sin imagen')
|
||
const bmp = await createImageBitmap(await res.blob())
|
||
const ar = bmp.width / bmp.height
|
||
// A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome.
|
||
let from = bmp, w = bmp.width, h = bmp.height, cv, cx
|
||
while (w > 64 || h > 64 || w !== h) {
|
||
w = Math.max(64, w >> 1)
|
||
h = Math.max(64, h >> 1)
|
||
cv = document.createElement('canvas')
|
||
cv.width = w
|
||
cv.height = h
|
||
cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 })
|
||
cx.imageSmoothingQuality = 'high'
|
||
cx.drawImage(from, 0, 0, w, h)
|
||
from = cv
|
||
}
|
||
bmp.close()
|
||
const d = cx.getImageData(0, 0, 64, 64).data
|
||
const out = new Uint8Array(256)
|
||
for (let y = 0; y < 16; y++) {
|
||
for (let x = 0; x < 16; x++) {
|
||
let sum = 0
|
||
for (let j = 0; j < 4; j++) {
|
||
for (let i = 0; i < 4; i++) {
|
||
const k = ((y * 4 + j) * 64 + x * 4 + i) * 4
|
||
sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2]
|
||
}
|
||
}
|
||
out[y * 16 + x] = Math.round(sum / 16)
|
||
}
|
||
}
|
||
return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 }
|
||
}
|
||
|
||
// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra.
|
||
async function creditPages(mangaId, parts) {
|
||
const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length }))
|
||
if (!chapters.length) return {}
|
||
const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } })
|
||
const first = await ask()
|
||
if (!first.missing.length) return first.hidden
|
||
const jobs = parts.filter(x => first.missing.includes(x.id))
|
||
const sigs = {}
|
||
let next = 0
|
||
await Promise.all(Array.from({ length: 3 }, async () => {
|
||
while (next < jobs.length) {
|
||
const x = jobs[next++], n = x.urls.length
|
||
try {
|
||
const pages = {}
|
||
for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i])
|
||
sigs[x.id] = pages
|
||
} catch {} // se volverá a intentar la próxima vez
|
||
}
|
||
}))
|
||
return (await ask(sigs)).hidden
|
||
}
|
||
|
||
// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo.
|
||
async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) {
|
||
const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))])
|
||
const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes
|
||
const vols = r?.volumes || []
|
||
const at = vols.findIndex(v => v.chapters.includes(chapterId))
|
||
if (at < 0) return null
|
||
const v = vols[at]
|
||
$('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`)
|
||
const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}`
|
||
const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id))
|
||
const parts = new Array(want.length)
|
||
let nextJob = 0
|
||
await Promise.all(Array.from({ length: 4 }, async () => {
|
||
while (nextJob < want.length) {
|
||
const k = nextJob++, { c, i } = want[k]
|
||
const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null)
|
||
if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages }
|
||
}
|
||
}))
|
||
const ok = parts.filter(Boolean)
|
||
if (!ok.some(x => x.id === chapterId)) return null
|
||
const cover = v.front ? await within(new Promise(res => {
|
||
const im = new Image()
|
||
im.onload = () => res(im.naturalWidth > 0)
|
||
im.onerror = () => res(false)
|
||
im.src = v.front
|
||
}), 15000) : false
|
||
// Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima.
|
||
const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {}
|
||
const pages = cover ? [v.front] : []
|
||
const starts = new Set()
|
||
for (const x of ok) {
|
||
const out = new Set(hidden[x.id] || [])
|
||
x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j))
|
||
x.start = pages.length
|
||
x.count = x.keep.length
|
||
starts.add(x.start)
|
||
pages.push(...x.keep.map(j => x.urls[j]))
|
||
}
|
||
const partOf = c => ok.find(x => x.i === c)
|
||
const tome = {
|
||
vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts,
|
||
has: c => !!partOf(c),
|
||
chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i },
|
||
chapterStart: c => partOf(c).start,
|
||
chapterPages: c => partOf(c).count,
|
||
// El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última
|
||
// visible es acabar el capítulo.
|
||
rawCount: c => partOf(c).urls.length,
|
||
rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] },
|
||
localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at },
|
||
// Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo.
|
||
async refresh(c) {
|
||
for (const x of ok) {
|
||
if (x.local || x.i < c || x.i > c + 1) continue
|
||
const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null)
|
||
if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]]
|
||
}
|
||
},
|
||
}
|
||
return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null }
|
||
}
|
||
|
||
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}`).catch(err => {
|
||
// Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo.
|
||
if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err
|
||
const pos = store.get(`bookPos:${mangaId}`)
|
||
return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 }
|
||
}),
|
||
])
|
||
const { manga, chapters } = info
|
||
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
|
||
const idx = chapters.findIndex(c => c.id === chapterId)
|
||
let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||
let prev = idx > 0 ? chapters[idx - 1] : null
|
||
let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
|
||
const isPaged = () => settings.mode !== 'vertical'
|
||
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
|
||
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
|
||
const isBook = !!chap.text
|
||
const noun = isBook ? 'libro' : 'manga'
|
||
const bookSize = () => {
|
||
const vw = window.innerWidth, vh = window.innerHeight
|
||
return { w: Math.floor(Math.min(isDouble() ? vw / 2 : vw, vh * (vh < 600 ? 1.05 : 0.8))), h: vh }
|
||
}
|
||
let book = null
|
||
if (isBook) {
|
||
settings.fit = 'height'
|
||
const opening = location.hash
|
||
app.innerHTML = '<div class="reader"><div class="loading">Preparando el libro…</div></div>'
|
||
book = await createBook(await loadBookText(mangaId), mangaId, bookSize())
|
||
if (location.hash !== opening) { book.release(); return }
|
||
book.busy = () => turning
|
||
}
|
||
// Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen
|
||
// siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto.
|
||
let tome = null, nextVol = null
|
||
if (!isBook && settings.tomes !== false && idx >= 0) {
|
||
const opening = location.hash
|
||
const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits)
|
||
if (location.hash !== opening) return
|
||
if (loaded) {
|
||
tome = loaded.tome
|
||
const first = tome.parts[0].i, last = tome.parts.at(-1).i
|
||
prev = first > 0 ? chapters[first - 1] : null
|
||
next = last < chapters.length - 1 ? chapters[last + 1] : null
|
||
nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo
|
||
}
|
||
}
|
||
const seq = book || tome // las páginas cruzan varios capítulos
|
||
const pages = tome ? tome.pages : chap.pages
|
||
const pageSrc = book ? i => book.src(i) : i => pages[i]
|
||
const total = book ? book.count : pages.length
|
||
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
|
||
if (book) {
|
||
// El progreso de un libro se guarda por capítulo. Con otra pantalla o tamaño de letra un capítulo tiene otro
|
||
// número de páginas: ir a la equivalente.
|
||
const c = Math.max(idx, 0)
|
||
const pagesHere = book.chapterPages(c)
|
||
if (chap.savedTotal > 1 && chap.savedTotal !== pagesHere) wanted = Math.round((wanted / (chap.savedTotal - 1)) * (pagesHere - 1))
|
||
wanted = book.chapterStart(c) + Math.min(wanted, pagesHere - 1)
|
||
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
|
||
bookResume = null
|
||
}
|
||
if (tome) {
|
||
const local = tome.localPage(idx, Math.max(wanted, 0))
|
||
wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local
|
||
}
|
||
let page = Math.min(Math.max(wanted, 0), total - 1)
|
||
let shown = [page] // páginas visibles (2 en doble página)
|
||
const lastShown = () => (isPaged() ? shown.at(-1) : page)
|
||
|
||
// Guardado del progreso (con debounce y al salir de la página).
|
||
let saveTimer = null
|
||
const bookPayload = (c, local, count) => ({
|
||
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
|
||
chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
|
||
})
|
||
const payload = () => {
|
||
if (!seq) return mangaPayload()
|
||
const p = lastShown(), c = seq.chapterAt(p)
|
||
const local = Math.max(0, p - seq.chapterStart(c))
|
||
return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c))
|
||
}
|
||
const mangaPayload = () => ({
|
||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
|
||
})
|
||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); 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()
|
||
|
||
let curChap = seq ? seq.chapterAt(page) : idx
|
||
const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0
|
||
const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown())
|
||
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||
app.innerHTML = `
|
||
<div class="reader">
|
||
<div class="rbar top">
|
||
<a class="btn small" href="${shelfTrip?.mangaId === mangaId ? '#/shelf' : `#/manga/${mangaId}`}" title="${shelfTrip?.mangaId === mangaId ? 'Volver al estante' : '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" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||
<button class="btn small" id="modequick"></button>
|
||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||
</div>
|
||
<div class="rpanel" hidden>
|
||
<select id="mode" title="Modo de lectura (se recuerda para este ${noun})">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||
<select id="fit" ${isBook ? 'hidden' : ''} title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||
<select id="dir" title="Dirección (se recuerda para este ${noun})"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||
${B3D_OK ? '<label class="row muted" style="font-size:.85rem" title="Muestra las páginas como un libro de verdad sobre una mesa"><input type="checkbox" id="b3dfx"> Libro 3D</label>' : ''}
|
||
${isBook ? '' : '<label class="row muted" style="font-size:.85rem" title="Lee los capítulos de un tomo como un solo libro, con su portada"><input type="checkbox" id="tomefx"> Por tomos</label>'}
|
||
<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>` : ''}
|
||
${isBook ? `<select id="bsize" title="Tamaño de letra">${[80, 90, 100, 110, 120, 135, 150, 175].map(n => `<option value="${n}">Letra ${n}%</option>`).join('')}</select>
|
||
<select id="bfont" title="Tipo de letra"><option value="serif">Con remates</option><option value="sans">Sin remates</option></select>
|
||
<select id="bmargin" title="Márgenes"><option value="small">Margen pequeño</option><option value="mid">Margen medio</option><option value="large">Margen grande</option></select>
|
||
<select id="bpaper" title="Papel"><option value="light">Papel claro</option><option value="sepia">Papel sepia</option><option value="dark">Papel oscuro</option></select>` : ''}
|
||
${OFFLINE_OK && !isBook ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||
<p class="muted">${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||
</div>
|
||
<div id="b3d" hidden></div>
|
||
<div id="pages"></div>
|
||
<div class="rbar bottom">
|
||
${seq ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||
<span id="pnum" class="muted"></span>
|
||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||
${seq ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||
</div>
|
||
</div>`
|
||
const reader = $('.reader', app)
|
||
let b3d = null, b3dLoading = false // el libro 3D, si está activo
|
||
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')
|
||
b3d?.setFilter(f)
|
||
reader.classList.toggle('bluelight', settings.filter === 'blue')
|
||
}
|
||
applyFilter()
|
||
if ($('#saver')) $('#saver').checked = settings.saver
|
||
if ($('#hd')) $('#hd').checked = settings.hd !== false
|
||
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
|
||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">${tome ? `Fin del tomo ${esc(tome.vol)}` : `Fin del capítulo ${esc(chapter.num ?? '')}`}</p>
|
||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id, nextVol ? 0 : null)}">${icon('next')} Siguiente: ${nextVol ? `Tomo ${esc(nextVol.vol)}` : 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á.`)
|
||
}
|
||
|
||
// Libro: al cruzar a otro capítulo leyendo, los que quedan atrás cuentan como leídos; a uno lejano (la barra, el
|
||
// selector) se salta sin marcar nada.
|
||
let lastBookPage = page
|
||
const syncBookNav = () => {
|
||
$('#bprev').style.display = curChap > 0 || (shown[0] ?? page) > 0 ? '' : 'none'
|
||
$('#bnext').style.display = curChap < chapters.length - 1 ? '' : 'none'
|
||
$('#chap').value = chapters[curChap].id
|
||
}
|
||
const syncBookChapter = () => {
|
||
const p = lastShown()
|
||
if (book) {
|
||
book.trim(p)
|
||
if (!isPaged()) {
|
||
box.querySelectorAll('img[src]').forEach(im => {
|
||
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
|
||
})
|
||
}
|
||
}
|
||
const c = seq.chapterAt(p)
|
||
if (c !== curChap) {
|
||
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
|
||
for (let j = curChap; j < c; j++) {
|
||
const count = tome ? tome.rawCount(j) : seq.chapterPages(j)
|
||
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
|
||
}
|
||
}
|
||
curChap = c
|
||
if (tome) { chapterId = chapters[c].id; chapter = chapters[c] }
|
||
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
|
||
}
|
||
lastBookPage = p
|
||
syncBookNav()
|
||
}
|
||
const setPageLabel = () => {
|
||
if (seq) syncBookChapter()
|
||
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
|
||
slider.value = lastShown() + 1
|
||
}
|
||
let observer = null
|
||
let atEnd = false
|
||
// Libro: ¿la pantalla ya pide otro tamaño de página? La barra del navegador móvil al aparecer no cuenta.
|
||
const sizeChanged = () => {
|
||
const s = bookSize()
|
||
return s.w !== book.W || Math.abs(s.h - book.H) > 120
|
||
}
|
||
function reopenBook() {
|
||
bookResume = { mangaId, word: book.wordAt(shown[0] ?? page) }
|
||
history.replaceState(null, '', readUrl(mangaId, chapters[curChap].id))
|
||
router()
|
||
}
|
||
|
||
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
|
||
const wide = new Set()
|
||
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;) {
|
||
// En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre.
|
||
const g = s === 0 || tome?.starts.has(s) || 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
|
||
}
|
||
preloaded.set(i, im)
|
||
const load = src => { im.src = src; im.decode?.().catch(() => {}) }
|
||
if (book) book.whenReady(i, load) // un libro dibuja sus páginas poco a poco, sin frenar la lectura
|
||
else load(pages[i])
|
||
}
|
||
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
|
||
scheduleLeafTextures()
|
||
}
|
||
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) }); if (b3dOn()) b3d?.show(shown) }
|
||
// 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="${pageSrc(i)}" alt="" draggable="false">`).join('')
|
||
const imgs = [...stage.querySelectorAll('img')]
|
||
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté
|
||
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 (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir))
|
||
if (b3dOn()) {
|
||
box.replaceChildren(stage)
|
||
syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag)
|
||
} else 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') && !b3dWanted()) 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: pageSrc(i) })
|
||
const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0
|
||
const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h
|
||
im.className = 'leafkeep'
|
||
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(dir = 0) {
|
||
finishTurn()
|
||
showSeq++ // descarta una página que estuviera esperando a cargar
|
||
clearTimeout(waitTimer)
|
||
box.classList.remove('waiting')
|
||
atEnd = true
|
||
resetZoom()
|
||
box.innerHTML = endBlock
|
||
if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima
|
||
else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir))
|
||
syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag)
|
||
}
|
||
|
||
function goTo(n, fromScroll = false, force = false, dir = 0) {
|
||
if (isPaged()) {
|
||
if (n >= total) return showEnd(dir)
|
||
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 || b3dWanted()) ? 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, -1); 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)
|
||
}
|
||
|
||
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
|
||
const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
|
||
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
|
||
const b3dOn = () => b3dWanted() && !atEnd
|
||
if (fromShelf) document.body.dataset.handoff = b3dWanted() ? '3d' : 'flat' // la foto del estante espera al libro 3D, o no (handoffCover)
|
||
const b3dPaper = () => {
|
||
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
|
||
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
|
||
}
|
||
// El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve.
|
||
function placeEnd(at, colors) {
|
||
const el = $('.chapter-end', box)
|
||
if (!el) return
|
||
el.classList.toggle('on', !!at)
|
||
if (!at) return
|
||
const o = box.getBoundingClientRect()
|
||
el.style.width = `${at.width}px`
|
||
el.style.height = `${at.height}px`
|
||
el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)`
|
||
if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v)
|
||
}
|
||
function syncB3d(dir = 0, drag = false) {
|
||
const on = b3dWanted(), host = $('#b3d', reader)
|
||
if (!host) return
|
||
reader.classList.toggle('b3d', on)
|
||
host.hidden = !on
|
||
if (!b3dWanted()) {
|
||
b3d?.destroy()
|
||
b3d = null
|
||
return
|
||
}
|
||
// Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces.
|
||
clearTimeout(leafTimer)
|
||
for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex)
|
||
leafTextures.clear()
|
||
if (b3d) {
|
||
if (!on) return
|
||
const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag)
|
||
if (ctl) turning = ctl
|
||
return
|
||
}
|
||
if (b3dLoading) return
|
||
b3dLoading = true
|
||
import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => {
|
||
b3dLoading = false
|
||
if (!b3dWanted() || !box.isConnected) return
|
||
b3d = m.createBook3D(host, {
|
||
total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i),
|
||
dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd,
|
||
})
|
||
if (!b3d) throw new Error('sin WebGL2')
|
||
applyFilter()
|
||
syncB3d()
|
||
}).catch(err => {
|
||
console.warn('Libro 3D:', err)
|
||
b3dLoading = false
|
||
settings.book3d = false
|
||
saveGlobal()
|
||
if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false
|
||
syncB3d()
|
||
toast('El libro 3D no está disponible en este dispositivo')
|
||
})
|
||
}
|
||
function render() {
|
||
observer?.disconnect()
|
||
const keep = lastShown()
|
||
if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página
|
||
resetZoom()
|
||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
|
||
renderModeQuick()
|
||
box.dataset.dir = settings.dir
|
||
syncB3d()
|
||
// 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]
|
||
// Las páginas de un libro tienen tamaño conocido: no hace falta cargar las anteriores para saltar a la guardada.
|
||
const near = i => (book ? Math.abs(i - page) <= 2 : i < page + 3)
|
||
box.innerHTML = Array.from({ length: total }, (_, i) => `<img data-i="${i}" ${near(i) ? `src="${pageSrc(i)}"` : 'data-lazy="1"'} ${book ? `style="aspect-ratio:${book.W}/${book.H}"` : ''} alt="">`).join('') + endBlock
|
||
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
|
||
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
|
||
if (!e.isIntersecting || !e.target.dataset.lazy) return
|
||
delete e.target.dataset.lazy
|
||
const i = Number(e.target.dataset.i)
|
||
if (book) book.whenReady(i, url => { if (!e.target.dataset.lazy) e.target.src = url })
|
||
else e.target.src = pageSrc(i)
|
||
}), { rootMargin: '2000px 0px' })
|
||
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
|
||
let ready = false
|
||
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 = (book ? [] : imgs.slice(0, page + 1)).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
|
||
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
|
||
imgs[page]?.scrollIntoView()
|
||
lastY = window.scrollY
|
||
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)
|
||
if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y)
|
||
$('#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 && !atEnd && (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 && !b3dWanted()) || e.target.closest('a,button,select,input')) return
|
||
// Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre.
|
||
if (e.isPrimary && pts.size) {
|
||
pts.clear()
|
||
tap = null
|
||
if (gesture?.type === 'turn') finishTurn()
|
||
gesture = null
|
||
}
|
||
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: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque
|
||
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
|
||
if (gesture) box.setPointerCapture?.(e.pointerId)
|
||
} else if (pts.size === 2 && atEnd) {
|
||
tap = null // la contraportada no se amplía
|
||
} 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 ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) {
|
||
const prev = [...shown], wasEnd = atEnd
|
||
finishTurn()
|
||
wantDrag = true
|
||
grabY = tap.y
|
||
try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null }
|
||
if (turning?.move) {
|
||
gesture = { type: 'turn', ctl: turning, prev, wasEnd, 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()
|
||
if (g.wasEnd) return showEnd() // se quedó en la contraportada
|
||
atEnd = false
|
||
if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación
|
||
})
|
||
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 && e.timeStamp - 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 && e.timeStamp - 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', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d })
|
||
const savePrefs = () => {
|
||
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
|
||
}
|
||
// En un libro, volver a maquetar reabre el lector: antes hay que esperar a que el modo nuevo esté guardado.
|
||
const savePrefsThenRender = () => { const saved = savePrefs(); if (book) saved.finally(render); else render() }
|
||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefsThenRender() })
|
||
$('#modequick').addEventListener('click', () => {
|
||
settings.mode = isPaged() ? 'vertical' : lastPaged
|
||
savePrefsThenRender()
|
||
})
|
||
$('#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, routePage())); router() })
|
||
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); 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 }
|
||
})
|
||
if ($('#tomefx')) {
|
||
$('#tomefx').checked = settings.tomes !== false
|
||
$('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
|
||
}
|
||
// Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte.
|
||
const inSeq = c => !!book || !!tome?.has(c)
|
||
$('#chap').addEventListener('change', e => {
|
||
const c = chapters.findIndex(x => x.id === e.target.value)
|
||
if (seq && inSeq(c)) goTo(seq.chapterStart(c))
|
||
else location.hash = readUrl(mangaId, e.target.value)
|
||
})
|
||
if (seq) {
|
||
$('#bnext').addEventListener('click', e => {
|
||
e.preventDefault()
|
||
if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1))
|
||
else location.hash = readUrl(mangaId, chapters[curChap + 1].id)
|
||
})
|
||
$('#bprev').addEventListener('click', e => {
|
||
e.preventDefault()
|
||
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
|
||
let c = curChap
|
||
while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c--
|
||
if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c))
|
||
else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0)
|
||
else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id)
|
||
else goTo(seq.chapterStart(c))
|
||
})
|
||
}
|
||
if (book) {
|
||
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) }
|
||
}
|
||
$('#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
|
||
reader.classList.remove('b3dcrop')
|
||
$('#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.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal
|
||
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 at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter
|
||
const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), 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 (atEnd) return
|
||
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 (book && sizeChanged()) return reopenBook()
|
||
if (!isPaged() || atEnd) return
|
||
box.classList.toggle('double', isDouble())
|
||
goTo(lastShown(), false, true)
|
||
}, 200)
|
||
}
|
||
window.addEventListener('resize', onResize)
|
||
|
||
if (book) {
|
||
const opts = bookSettings()
|
||
for (const [id, key] of [['bsize', 'size'], ['bfont', 'font'], ['bmargin', 'margin'], ['bpaper', 'paper']]) {
|
||
$(`#${id}`).value = String(opts[key])
|
||
$(`#${id}`).addEventListener('change', e => {
|
||
store.set('bookSettings', { ...bookSettings(), [key]: key === 'size' ? Number(e.target.value) : e.target.value })
|
||
reopenBook()
|
||
})
|
||
}
|
||
}
|
||
if ($('#b3dfx')) {
|
||
$('#b3dfx').checked = !!settings.book3d
|
||
$('#b3dfx').addEventListener('change', e => {
|
||
settings.book3d = e.target.checked
|
||
saveGlobal()
|
||
// iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar.
|
||
if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {})
|
||
render()
|
||
})
|
||
}
|
||
render()
|
||
cleanup = () => {
|
||
b3d?.destroy()
|
||
b3d = null
|
||
book?.release()
|
||
clearInterval(tomeTimer)
|
||
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>`
|
||
// ---------- Estante 3D ----------
|
||
const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles
|
||
if (!SHELF_OK) document.querySelector('#topbar nav a[href="#/shelf"]')?.remove()
|
||
// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado.
|
||
function handoffCover(src) {
|
||
document.querySelector('.handoff')?.remove()
|
||
const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' })
|
||
document.body.append(img)
|
||
document.body.classList.add('handing')
|
||
const t0 = performance.now()
|
||
let mounted = 0 // cuándo se supo que el lector se monta sin libro 3D
|
||
delete document.body.dataset.handoff // lo dice el lector al montarse: '3d' o 'flat'
|
||
const fade = why => { img.dataset.why = why; delete document.body.dataset.handoff; document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
|
||
const wait = () => {
|
||
if (!img.isConnected) return document.body.classList.remove('handing')
|
||
const ready = document.querySelector('#b3d:not([hidden]) canvas')
|
||
if (ready) return setTimeout(() => fade('3d'), 350) // las texturas de la tapa tardan un instante
|
||
// Montado sin libro 3D (modo vertical, ajuste a lo ancho): ya no habrá lienzo que esperar. Con él, se espera a que dibuje.
|
||
if (!mounted && document.body.dataset.handoff === 'flat') mounted = performance.now()
|
||
// El lector no llegó a montarse (un error en su lugar). Mientras siga el estante, la ruta aún no ha cambiado de vista.
|
||
const failed = !document.querySelector('.reader, #shelf3d') && performance.now() - t0 > 400
|
||
if (failed) return fade('error')
|
||
if (mounted && performance.now() - mounted > 600) return fade('flat')
|
||
if (performance.now() - t0 > 90000) return fade('tarde') // por si el lector se queda colgado: mientras carga, la foto sigue
|
||
if (!location.hash.startsWith('#/read/')) return fade('ruta')
|
||
setTimeout(wait, 60)
|
||
}
|
||
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
|
||
return (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => new Promise(r => requestAnimationFrame(() => { setTimeout(wait, 120); r() }))) // para entonces ya se está en el lector
|
||
}
|
||
// Lo último que se supo del estante, en este dispositivo: con ello se monta al momento (sin esperar a cada obra)
|
||
// y sin conexión. Tomos y logos se refrescan luego, y solo se rehace lo que cambie.
|
||
const shelfSnapKey = () => `shelfSnap:${me?.username || ''}`
|
||
const LOGO_DAYS = { found: 30, none: 7 }
|
||
const shelfSort = (works, order) => order === 'az'
|
||
? [...works].sort((a, b) => String(a.title).replace(/^[^\p{L}\p{N}]+/u, '').localeCompare(String(b.title).replace(/^[^\p{L}\p{N}]+/u, ''), 'es', { sensitivity: 'base', numeric: true }))
|
||
: [...works].sort((a, b) => (b.last || 0) - (a.last || 0))
|
||
async function viewShelf(params) {
|
||
if (!SHELF_OK) {
|
||
app.innerHTML = `<h1>Estante 3D</h1><p class="muted">Este navegador no tiene WebGL2.</p><p><a class="btn" href="#/library">${icon('library')} Volver a la librería</a></p>`
|
||
return
|
||
}
|
||
document.body.classList.add('reading') // sin la barra de arriba
|
||
let order = store.get('shelfOrder', 'recent')
|
||
const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`)
|
||
app.innerHTML = `<div id="shelf3d"></div><div class="shelftools"><a class="btn small" href="#/library">${icon('back')} Librería</a><button class="btn small" id="shelforder" type="button" title="Cambiar el orden"></button></div>
|
||
<p class="shelfnote" id="shelfnote"></p>
|
||
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
|
||
const note = $('#shelfnote'), card = $('#shelfpeek'), orderBtn = $('#shelforder')
|
||
orderBtn.innerHTML = orderLabel()
|
||
const say = (text, ms) => { clearTimeout(note.t); note.textContent = text; note.hidden = !text; if (ms) note.t = setTimeout(() => { note.hidden = true }, ms) }
|
||
const focusOn = shelfTrip?.mangaId || params?.get('focus') || null // al volver del lector, o desde la ficha
|
||
shelfTrip = null
|
||
let shelf = null, gone = false, offline = !serverReachable
|
||
const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() }
|
||
document.addEventListener('keydown', onKey)
|
||
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); clearTimeout(snapTimer); saveSnap(); shelf?.destroy(); shelf = null }
|
||
// Foto del estante en el dispositivo.
|
||
const snap = store.get(shelfSnapKey(), null)
|
||
const logos = snap?.logos || {} // id -> { url, at }
|
||
let works = [], snapTimer = 0
|
||
const saveSnap = () => { if (works.length && !offline) store.set(shelfSnapKey(), { at: Date.now(), works, logos }) }
|
||
const saveSoon = () => { clearTimeout(snapTimer); snapTimer = setTimeout(saveSnap, 1500) }
|
||
// Sin conexión, cada tomo se lee por los capítulos guardados en el dispositivo.
|
||
const onDevice = id => !offline || offHas(id)
|
||
// Vistazo: lo que se sabe del tomo sacado, junto a él.
|
||
const readOf = info => {
|
||
const { work, vol, state } = info
|
||
if (offline) {
|
||
if (state === 'reading' && work.chapterId && offHas(work.chapterId) && (!vol.chapters?.length || vol.chapters.includes(work.chapterId))) return readUrl(work.id, work.chapterId, work.page || 0)
|
||
const here = (vol.chapters || []).find(offHas)
|
||
return here ? readUrl(work.id, here) : null
|
||
}
|
||
if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0)
|
||
if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0])
|
||
return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}`
|
||
}
|
||
// A la mesa: el estante posa el tomo como el lector lo mostrará y una foto del último fotograma tapa el cambio.
|
||
let leaving = false
|
||
const toTable = info => {
|
||
if (leaving) return
|
||
const href = readOf(info)
|
||
if (!href) return say('Este tomo no está guardado en el dispositivo.', 2500)
|
||
const go = async snap => {
|
||
if (gone) return
|
||
if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id }
|
||
if (snap) await handoffCover(snap) // la foto ya está pintada antes de quitar el estante: no se ve nada entre medias
|
||
location.hash = href
|
||
}
|
||
leaving = true
|
||
if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null)
|
||
}
|
||
// El tomo sacado lleva su nota, como la de la balda, con lo que se sabe de él.
|
||
const showPeek = info => {
|
||
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
|
||
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
|
||
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : ''].filter(Boolean).join(' · ')
|
||
// Por dónde va, escrito: el capítulo de la obra (si se sabe su número) y la página.
|
||
const page = work.page > 0 ? `, página ${work.page + 1}` : ''
|
||
const where = state === 'read' ? 'Ya leído'
|
||
: state === 'reading' || state === 'unknown' ? (work.chapterNum != null && work.chapterNum !== '' ? `Vas por el capítulo ${esc(work.chapterNum)}${page}` : at >= 0 ? `Vas por el capítulo ${at + 1} de este tomo${page}` : 'Lo estás leyendo')
|
||
: 'Sin empezar'
|
||
const href = readOf(info), st = shelfMod.noteStyle(work.id)
|
||
card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg`
|
||
// Un pósit de verdad: sin botones. Se lee tocando el tomo (o esta línea, que lleva al mismo sitio).
|
||
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
|
||
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
|
||
<p class="pwhere">${where}</p>
|
||
${href ? `<a class="phint" id="peekread" href="${href}">Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →</a>` : '<p class="phint">No está guardado en el dispositivo</p>'}`
|
||
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
|
||
card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) })
|
||
clearTimeout(card.hideTimer)
|
||
card.hidden = false
|
||
requestAnimationFrame(() => card.classList.add('on'))
|
||
}
|
||
const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || ''
|
||
let shelfMod = null
|
||
// Lo guardado en el dispositivo, para cuando no hay servidor.
|
||
const offlineWorks = () => {
|
||
const known = new Map((snap?.works || []).map(w => [w.id, w]))
|
||
return offByManga().filter(m => !isBookId(m.mangaId)).map(m => known.get(m.mangaId) || { id: m.mangaId, title: m.title, cover: m.cover, last: m.at, chapterId: null, page: 0, lang: m.chapters[0]?.lang || null })
|
||
}
|
||
const start = (list, lib) => {
|
||
works = list
|
||
shelf = shelfMod.createShelf3D($('#shelf3d'), lib, {
|
||
works: shelfSort(works, order),
|
||
still: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
onPeek: showPeek,
|
||
onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa
|
||
table: me?.tableV ? `/api/table?v=${me.tableV}` : null,
|
||
// Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos.
|
||
pagesOf: offline ? null : chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []),
|
||
})
|
||
if (!shelf) throw new Error('sin WebGL2')
|
||
if (focusOn) shelf.focus(focusOn)
|
||
for (const w of works) if (logos[w.id]?.url) shelf.setLogo(w.id, logos[w.id].url)
|
||
}
|
||
orderBtn.addEventListener('click', () => {
|
||
order = order === 'az' ? 'recent' : 'az'
|
||
store.set('shelfOrder', order)
|
||
orderBtn.innerHTML = orderLabel()
|
||
shelf?.unpeek()
|
||
shelf?.setWorks(shelfSort(works, order))
|
||
shelf?.toTop()
|
||
})
|
||
try {
|
||
const [lib, mod] = await Promise.all([import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
|
||
shelfMod = mod
|
||
if (gone) return
|
||
mod.loadNoteFonts()
|
||
// Con foto guardada, el mueble sale ya; la lista del servidor llega después.
|
||
if (snap?.works?.length && !offline) start(snap.works.filter(w => wantsId(w.id)), lib)
|
||
else if (!snap?.works?.length && !offline) say('Colocando los tomos…')
|
||
let fresh = null
|
||
if (!offline) fresh = await api('/api/shelf').catch(e => { if (e.message === NO_SERVER || !serverReachable) offline = true; else throw e; return null })
|
||
if (gone) return
|
||
if (offline) {
|
||
const list = offlineWorks()
|
||
if (shelf) { works = list; shelf.setWorks(shelfSort(works, order)) } else if (list.length) start(list, lib)
|
||
return say(list.length ? 'Sin conexión: solo lo guardado en este dispositivo.' : 'Sin conexión, y no hay tomos guardados en este dispositivo.', list.length ? 4000 : 0)
|
||
}
|
||
// La lista del servidor manda; de la foto se conservan los tomos ya conocidos.
|
||
const had = new Map(works.map(w => [w.id, w]))
|
||
const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w))
|
||
if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') }
|
||
const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.chapterNum === w.chapterNum && o.page === w.page && o.last === w.last })
|
||
if (!shelf) start(next, lib)
|
||
else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) }
|
||
else works = next.map(w => had.get(w.id))
|
||
saveSoon()
|
||
// Los tomos llegan aparte, de pocos en pocos: primero las obras que aún no tienen, luego se repasan las demás.
|
||
const queue = [...works.filter(w => !w.vols), ...works.filter(w => w.vols)]
|
||
let pending = queue.filter(w => !w.vols).length
|
||
if (!pending) say('')
|
||
const worker = async () => {
|
||
while (queue.length && !gone) {
|
||
const w = queue.shift(), first = !w.vols
|
||
const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
|
||
if (gone) return
|
||
const vols = r?.volumes?.length ? r.volumes : null
|
||
if (vols && JSON.stringify(vols) !== JSON.stringify(w.vols || null)) { w.vols = vols; shelf.setVolumes(w.id, vols); saveSoon() }
|
||
if (first && --pending <= 0) say('')
|
||
}
|
||
}
|
||
for (let k = 0; k < 3; k++) worker()
|
||
// Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa.
|
||
const day = 864e5, now = Date.now()
|
||
const logoQueue = works.filter(w => { const l = logos[w.id]; return !l || now - (l.at || 0) > (l.url ? LOGO_DAYS.found : LOGO_DAYS.none) * day })
|
||
const logoWorker = async () => {
|
||
while (logoQueue.length && !gone) {
|
||
const w = logoQueue.shift()
|
||
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => undefined)
|
||
if (gone) return
|
||
if (r === undefined) continue // error: se vuelve a preguntar en la próxima visita
|
||
logos[w.id] = { url: r.logo || null, at: Date.now() }
|
||
if (r.logo) shelf.setLogo(w.id, r.logo)
|
||
saveSoon()
|
||
}
|
||
}
|
||
for (let k = 0; k < 2; k++) logoWorker()
|
||
} catch (e) {
|
||
if (!gone) say(`No se pudo abrir el estante: ${e.message}`)
|
||
}
|
||
}
|
||
|
||
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().filter(m => wantsId(m.mangaId)) : []
|
||
const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id))
|
||
const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id))
|
||
const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id))
|
||
const tabs = [
|
||
['reading', 'Leyendo', 'book', reading.length],
|
||
['following', 'Siguiendo', 'bell', follows.length],
|
||
['finished', 'Terminados', 'check', finished.length],
|
||
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
|
||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
|
||
]
|
||
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
|
||
app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('shelf')} Estante 3D</a>` : ''}</div>
|
||
<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.filter(it => wantsId(it.manga_id)).map(it => libCard({
|
||
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
|
||
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
|
||
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
|
||
} 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>./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 ----------
|
||
// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura.
|
||
async function tableJpeg(file) {
|
||
const url = URL.createObjectURL(file)
|
||
try {
|
||
const im = new Image()
|
||
await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url })
|
||
const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight))
|
||
const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) })
|
||
const g = c.getContext('2d')
|
||
g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa
|
||
g.fillRect(0, 0, c.width, c.height)
|
||
g.imageSmoothingQuality = 'high'
|
||
g.drawImage(im, 0, 0, c.width, c.height)
|
||
const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88))
|
||
if (!blob) throw new Error('No se pudo preparar la imagen')
|
||
return blob
|
||
} finally { URL.revokeObjectURL(url) }
|
||
}
|
||
// Laboratorio del giroscopio: solo para desarrollo, se borrará (con public/gyrolab.js, gyrolab.js del servidor y su botón en Ajustes).
|
||
async function viewGyroLab() {
|
||
app.innerHTML = '<p class="muted">Cargando el laboratorio…</p>'
|
||
const m = await import(`/gyrolab.js?v=${Date.now()}`)
|
||
if (!location.hash.startsWith('#/gyrolab')) return
|
||
cleanup = m.mountGyroLab(app, { esc, toast })
|
||
}
|
||
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>${icon('book')} Qué mostrar</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.</p>
|
||
<select id="contentmode">${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `<option value="${k}" ${k === contentMode() ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||
<section><b>${icon('book')} Libro 3D: fondo de la mesa</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">Pon cualquier imagen en lugar de la madera. Se guarda en tu cuenta y se usa en todos tus dispositivos.</p>
|
||
<img id="tableprev" class="tableprev" alt="Fondo actual de la mesa" hidden>
|
||
<div class="row" style="flex-wrap:wrap;gap:8px">
|
||
<label class="btn small">${icon('image')} Elegir imagen<input type="file" id="tablefile" accept="image/*" hidden></label>
|
||
<button class="btn small danger" id="tabledel" hidden>${icon('trash')} Volver a la madera</button>
|
||
</div></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><b>${icon('phone')} Desarrollo: laboratorio del giroscopio</b>
|
||
<p class="muted" style="margin:0;font-size:.88rem">Graba los sensores de movimiento de este dispositivo para calibrar el giroscopio del estante y del libro 3D. Es temporal: se quitará al terminar.</p>
|
||
<div class="row"><a class="btn small" href="#/gyrolab">${icon('phone')} Iniciar el laboratorio</a></div></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://localhost: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>`
|
||
const showTable = () => {
|
||
const prev = $('#tableprev')
|
||
if (!prev) return
|
||
prev.hidden = !me.tableV
|
||
if (me.tableV) prev.src = `/api/table?v=${me.tableV}`
|
||
else prev.removeAttribute('src')
|
||
$('#tabledel').hidden = !me.tableV
|
||
}
|
||
showTable()
|
||
$('#tablefile').addEventListener('change', async e => {
|
||
const f = e.target.files[0]
|
||
e.target.value = ''
|
||
if (!f) return
|
||
try {
|
||
const blob = await tableJpeg(f)
|
||
const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob })
|
||
const data = await res.json().catch(() => ({}))
|
||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||
me.tableV = data.tableV
|
||
showTable()
|
||
toast('Fondo guardado')
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
$('#tabledel').addEventListener('click', async () => {
|
||
try {
|
||
await api('/api/table', { method: 'DELETE' })
|
||
me.tableV = 0
|
||
showTable()
|
||
toast('La mesa vuelve a ser de madera')
|
||
} catch (err) { toast(err.message) }
|
||
})
|
||
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') })
|
||
$('#contentmode').addEventListener('change', async e => {
|
||
const prev = contentMode()
|
||
try {
|
||
await api('/api/settings', { method: 'POST', body: { contentMode: e.target.value } })
|
||
me.content_mode = e.target.value
|
||
toast('Guardado')
|
||
} catch (err) { e.target.value = prev; toast(err.message) }
|
||
})
|
||
$('#pw').addEventListener('submit', async e => {
|
||
e.preventDefault()
|
||
try {
|
||
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) }
|
||
})
|
||
}
|