Fuente M440, modo sin conexión (servidor sin internet y app sin servidor), cola del dispositivo en Colas y arreglo del reescalado de tiras altas
- M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola. - Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión. - App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente. - Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor. - Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla. - Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo. - Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+228
-31
@@ -24,6 +24,7 @@ const ICONS = {
|
||||
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"/>',
|
||||
@@ -49,7 +50,8 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
|
||||
// Iconos del menú superior (index.html).
|
||||
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
|
||||
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', md: 'MangaDex (multi-idioma)' }
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
|
||||
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
|
||||
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
|
||||
const FILTERS = {
|
||||
none: ['Sin filtro', ''],
|
||||
@@ -112,17 +114,82 @@ function toast(msg) {
|
||||
clearTimeout(toast.t)
|
||||
toast.t = setTimeout(() => (t.hidden = true), 2500)
|
||||
}
|
||||
const NO_SERVER = 'Sin conexión con el servidor'
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
// 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'
|
||||
@@ -140,7 +207,7 @@ const OFF_CACHE = 'offline-v1'
|
||||
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').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() }
|
||||
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() {
|
||||
@@ -165,19 +232,40 @@ async function offRun() {
|
||||
const it = offState.queue.shift()
|
||||
offState.running = { ...it, done: 0, total: 0 }
|
||||
offEmit()
|
||||
try { await offSaveChapter(it) } catch (err) { toast(`No se pudo guardar ${it.label}: ${err.message}`) }
|
||||
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)
|
||||
const res = await fetch(chapUrl, { signal })
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`)
|
||||
const data = await res.clone().json()
|
||||
await cache.put(chapUrl, res)
|
||||
saved.push(chapUrl)
|
||||
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
|
||||
const mres = await fetch(mUrl).catch(() => null)
|
||||
if (mres?.ok) await cache.put(mUrl, mres)
|
||||
@@ -186,17 +274,33 @@ async function offSaveChapter(it) {
|
||||
let bytes = 0, i = 0
|
||||
const worker = async () => {
|
||||
while (i < data.pages.length) {
|
||||
stop()
|
||||
const u = data.pages[i++]
|
||||
const r = await fetch(u)
|
||||
if (!r.ok) throw new Error(`página ${r.status}`)
|
||||
const blob = await r.blob()
|
||||
// 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
|
||||
await cache.put(u, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || 'image/jpeg' } }))
|
||||
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)
|
||||
@@ -246,7 +350,7 @@ async function router() {
|
||||
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
|
||||
|
||||
if (!me) {
|
||||
const r = await api('/api/me').catch(() => ({}))
|
||||
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)
|
||||
@@ -301,14 +405,14 @@ function viewAuth(signup) {
|
||||
// ---------- Inicio ----------
|
||||
function mangaCard(m) {
|
||||
return `<a class="card" href="#/manga/${m.id}">
|
||||
<img class="cover" loading="lazy" src="${esc(m.cover || '/icon.svg')}" alt="">
|
||||
<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">
|
||||
<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>' : ''}
|
||||
@@ -363,25 +467,61 @@ async function loadRecs(refresh = false) {
|
||||
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
|
||||
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) })
|
||||
}
|
||||
// Portada sin internet en el servidor: lo descargado en el servidor, con buscador local.
|
||||
async function viewHomeOffline(params) {
|
||||
app.innerHTML = `
|
||||
<section id="cont"></section>
|
||||
<h2>${icon('download')} Descargado en el servidor</h2>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
|
||||
<div id="results" class="loading">Cargando…</div>`
|
||||
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||||
const have = new Set(list.map(m => m.id))
|
||||
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
|
||||
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
const res = $('#results')
|
||||
const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
const render = () => {
|
||||
const words = fold($('[name=q]').value).split(/\s+/).filter(Boolean)
|
||||
const shown = list.filter(m => words.every(w => fold(m.title).includes(w)))
|
||||
res.className = shown.length ? 'grid' : 'loading'
|
||||
res.innerHTML = shown.length ? shown.map(mangaCard).join('') : list.length ? 'Nada descargado con ese nombre.' : 'No hay nada descargado en el servidor todavía.'
|
||||
}
|
||||
$('form.search').addEventListener('submit', e => e.preventDefault())
|
||||
$('[name=q]').addEventListener('input', render)
|
||||
render()
|
||||
}
|
||||
|
||||
// Portada sin conexión con el servidor: lo guardado en este dispositivo.
|
||||
function viewHomeDevice() {
|
||||
const dev = OFFLINE_OK ? offByManga() : []
|
||||
app.innerHTML = `<h2>${icon('phone')} En este dispositivo</h2>` + (dev.length
|
||||
? `<div class="grid">${dev.map(m => mangaCard({ id: m.mangaId, title: m.title, cover: m.cover, sub: `${plural(m.chapters.length, 'capítulo')} guardado${m.chapters.length === 1 ? '' : 's'}` })).join('')}</div>`
|
||||
: `<p class="muted">${OFFLINE_OK ? 'No hay capítulos guardados en este dispositivo. Con conexión, usa «Guardar en el dispositivo» en la ficha o en el lector para leer sin internet.'
|
||||
: 'Leer sin conexión necesita abrir la app por HTTPS.'}</p>`)
|
||||
}
|
||||
|
||||
async function viewHome(params) {
|
||||
if (!serverReachable) return viewHomeDevice()
|
||||
if (!serverOnline) return viewHomeOffline(params)
|
||||
const q = params.get('q') || ''
|
||||
const tag = params.get('tag') || ''
|
||||
const tagName = params.get('tagName') || tag
|
||||
const mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||||
const anyLang = params.get('any') === '1'
|
||||
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
|
||||
let mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||||
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
|
||||
store.set('source', source)
|
||||
const showCats = mode === 'cats' && !tag
|
||||
app.innerHTML = `
|
||||
<section id="cont"></section>
|
||||
<section id="recs"></section>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
|
||||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||||
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
|
||||
<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button></div>`}
|
||||
${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>`
|
||||
@@ -413,8 +553,8 @@ async function viewHome(params) {
|
||||
return
|
||||
}
|
||||
|
||||
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías (sus otros listados no paginan).
|
||||
const pageable = source === 'md' || !!tag
|
||||
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
|
||||
const pageable = source === 'md' || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const seen = new Set()
|
||||
let page = 1
|
||||
const load = async () => {
|
||||
@@ -446,9 +586,10 @@ async function viewManga(id, langOverride) {
|
||||
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 === 'lc'
|
||||
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
|
||||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||||
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
|
||||
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
|
||||
const first = chapters[0]
|
||||
let mainBtn = ''
|
||||
if (progress && chapters.some(c => c.id === progress.chapter_id)) {
|
||||
@@ -459,7 +600,8 @@ async function viewManga(id, langOverride) {
|
||||
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||||
|
||||
const menuItems = [
|
||||
isLc ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'lc' ? '' : `<a href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' ? '' : `<a href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
@@ -489,7 +631,7 @@ async function viewManga(id, langOverride) {
|
||||
</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 <a class="tag" href="${altSearch}">buscarlo en LeerCapitulo</a>.
|
||||
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>`
|
||||
|
||||
@@ -955,7 +1097,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
let saveTimer = null
|
||||
const payload = () => ({
|
||||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||||
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||||
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
|
||||
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
|
||||
})
|
||||
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
|
||||
@@ -983,7 +1125,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||||
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
@@ -1581,7 +1723,9 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
|
||||
async function viewLibrary(params) {
|
||||
const tab = params.get('tab') || 'reading'
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const [hist, fol, lists] = await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
|
||||
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() : []
|
||||
@@ -1712,7 +1856,15 @@ async function viewQueues() {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
const here = () => location.hash === '#/queues'
|
||||
if (!$('#qwrap')) app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const [q, list] = await Promise.all([api('/api/queues'), api('/api/downloads')])
|
||||
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))
|
||||
@@ -1733,7 +1885,36 @@ async function viewQueues() {
|
||||
<div class="sub">${lines}</div>
|
||||
${actions ? `<div class="qact">${actions}</div>` : ''}
|
||||
</div>`
|
||||
const html = `<h1>Colas</h1>
|
||||
// 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({
|
||||
@@ -1744,6 +1925,7 @@ async function viewQueues() {
|
||||
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>'}
|
||||
@@ -1788,6 +1970,7 @@ async function viewQueues() {
|
||||
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) {
|
||||
if (!confirm(`¿Eliminar la copia local de «${b.dataset.title}» (${fmtSize(Number(b.dataset.size))})?\nTu historial de lectura no se toca.`)) return
|
||||
@@ -1801,8 +1984,22 @@ async function viewQueues() {
|
||||
}
|
||||
// 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(), busy ? 2000 : 15000)
|
||||
cleanup = () => clearTimeout(t)
|
||||
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 ----------
|
||||
|
||||
@@ -346,3 +346,9 @@ body.noscroll { overflow: hidden; }
|
||||
.recrow .card { scroll-snap-align: start; }
|
||||
@media (max-width: 640px) { .recrow { grid-auto-columns: 115px; gap: 10px; } }
|
||||
.banlist { display: grid; gap: 6px; margin-top: 8px; } .banlist .row { gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* Aviso: el servidor no tiene internet */
|
||||
#netbanner { position: sticky; top: 53px; z-index: 19; display: flex; align-items: center; gap: 10px; padding: 8px 16px;
|
||||
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
|
||||
#netbanner svg { flex: none; width: 18px; height: 18px; }
|
||||
body.reading #netbanner { display: none; }
|
||||
|
||||
+27
-9
@@ -13,20 +13,38 @@ self.addEventListener('activate', e => {
|
||||
.then(() => self.clients.claim()))
|
||||
})
|
||||
|
||||
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión.
|
||||
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. Si hay copia guardada
|
||||
// y el servidor no contesta en NET_WAIT ms (red que no llega, servidor caído o colgado), se usa la copia
|
||||
// sin esperar a que el navegador dé la conexión por perdida; la petición sigue y actualiza la caché.
|
||||
const NET_WAIT = 3000
|
||||
const DOWN_FOR = 20e3
|
||||
let downUntil = 0
|
||||
// La app distingue así una copia guardada de una respuesta real del servidor.
|
||||
async function fromCache(res) {
|
||||
const headers = new Headers(res.headers)
|
||||
headers.set('x-from-cache', '1')
|
||||
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
|
||||
}
|
||||
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) {
|
||||
try {
|
||||
const res = await fetch(req)
|
||||
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
|
||||
const net = fetch(req).then(async res => {
|
||||
if (store && res.ok) {
|
||||
const c = await caches.open(cacheName)
|
||||
if (store === true || (await c.match(req, { ignoreSearch }))) c.put(req, res.clone())
|
||||
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
|
||||
}
|
||||
return res
|
||||
} catch (err) {
|
||||
const hit = await caches.match(req, { ignoreSearch, cacheName }) || (fallback && await caches.match(fallback))
|
||||
if (hit) return hit
|
||||
throw err
|
||||
}
|
||||
})
|
||||
net.then(() => { downUntil = 0 }, () => {})
|
||||
const hit = await cached()
|
||||
if (!hit) return net // sin copia: solo queda esperar a la red
|
||||
// Si hace poco que el servidor no contestaba, la copia directamente (la red sigue intentándolo).
|
||||
if (Date.now() < downUntil) return fromCache(hit)
|
||||
let timer
|
||||
const timeout = new Promise(r => { timer = setTimeout(() => r(null), NET_WAIT) })
|
||||
const res = await Promise.race([net.catch(() => null), timeout])
|
||||
clearTimeout(timer)
|
||||
if (!res) downUntil = Date.now() + DOWN_FOR
|
||||
return res || fromCache(hit)
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
|
||||
Reference in New Issue
Block a user