Descarga de capítulos sueltos: rango o selección en la lista

Las descargas tienen modo 'all' (manga entero, también lo nuevo) o 'some' (solo
los capítulos pedidos, en download_wanted). El selector de capítulos se comparte
con el reescalado HD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 11:24:38 -06:00
co-authored by Claude Opus 5.5
parent bc84a9f6fe
commit aabb87f6c1
4 changed files with 215 additions and 123 deletions
+173 -113
View File
@@ -304,7 +304,6 @@ async function viewManga(id, langOverride) {
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
let hdSet = new Set(upscale?.chapters || []), hdJobs = upscale?.jobs || {}
const newSet = new Set(newKeys.map(String))
let isFollowing = following
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
@@ -354,34 +353,182 @@ async function viewManga(id, langOverride) {
${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>`
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>`
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`
// --- 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`)
},
},
}
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()
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
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 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>`
const renderDl = d => {
const box = $('#dl')
if (!box) return
const active = d && DL_ACTIVE.includes(d.status)
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 los capítulos en CBZ en el servidor para leerlos sin conexión (y poder reescalarlos a HD).</div>'
actions = `<button class="btn small" id="dlgo">${icon('download')} Descargar manga</button>`
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 = `<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
actions = `${pickBtn('Añadir capítulos…')}<button class="btn small" id="dlcancel">${icon('stop')} Cancelar</button>${delBtn}`
} else {
status = d.status === 'done' ? `${icon('check')} ${d.done} capítulos${d.size ? ` · ${fmtSize(d.size)}` : ''}` : downloadLabel(d)
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>` : '')
actions = `<button class="btn small" id="dlgo" title="Descarga los capítulos que falten">${icon(d.status === 'cancelled' ? 'download' : 'refresh')} ${d.status === 'error' ? 'Reintentar' : d.status === 'cancelled' ? 'Continuar descarga' : 'Buscar capítulos nuevos'}</button>${delBtn}`
+ (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}`
}
box.innerHTML = secHead('download', 'Copia local', status) + body + (offline ? '' : `<div class="psec-actions">${actions}</div>`)
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(`¿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
@@ -389,6 +536,9 @@ async function viewManga(id, langOverride) {
toast('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)
@@ -398,103 +548,14 @@ async function viewManga(id, langOverride) {
fresh?.downloaded.forEach(x => dlSet.add(x))
if (fresh?.download) next.size = fresh.download.size
if (fresh?.upscale) renderHd(fresh.upscale)
renderList()
refreshSel()
}
renderDl(next)
}, 2000)
}
// --- Reescalado HD: todo lo pendiente, un rango o una selección hecha en la lista.
// --- Reescalado HD de lo descargado.
let hdTimer = null
let hdInfo = upscale
let hdForm = null // null (cerrado) | 'pending' | 'range' | 'pick'
const picked = new Set()
let lastPick = null
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`
// Descargados, sin HD y sin estar ya en cola (los fallidos sí, para reintentar). En orden de lectura.
const hdCandidates = () => chapters.filter(c => dlSet.has(c.id) && !hdSet.has(c.id) && (!hdJobs[c.id] || hdJobs[c.id] === 'error'))
const estimate = ids => {
const pages = ids.reduce((n, cid) => n + (hdInfo?.pages?.[cid] || 0), 0)
return `${ids.length} capítulo${ids.length === 1 ? '' : 's'} · ${pages} págs. · ${fmtDur(pages * (hdInfo?.secPerPage || 13))}`
}
const formIds = () => {
const cand = hdCandidates()
if (hdForm === 'pending') return cand.map(c => c.id)
if (hdForm === 'pick') return cand.filter(c => picked.has(c.id)).map(c => c.id)
if (hdForm === 'range') {
const dls = chapters.filter(c => dlSet.has(c.id))
let a = dls.findIndex(c => c.id === $('#hdfrom')?.value), b = dls.findIndex(c => c.id === $('#hdto')?.value)
if (a < 0 || b < 0) return []
if (a > b) [a, b] = [b, a]
const inRange = new Set(dls.slice(a, b + 1).map(c => c.id))
return cand.filter(c => inRange.has(c.id)).map(c => c.id)
}
return []
}
const updateSummary = () => {
const ids = formIds()
const sum = $('#hdsum')
if (sum) sum.textContent = ids.length ? estimate(ids) : hdForm === 'pick' ? 'Marca capítulos en la lista de abajo.' : 'No hay capítulos pendientes en esa selección.'
if ($('#hdrun')) $('#hdrun').disabled = !ids.length
const bar = $('#selbar')
if (bar) {
bar.hidden = hdForm !== 'pick'
if (hdForm === 'pick') bar.innerHTML = `<span>${ids.length ? estimate(ids) : 'Ningún capítulo marcado'}</span>
<button class="btn small primary" id="selrun" ${ids.length ? '' : 'disabled'}>${icon('sparkles')} Reescalar</button>
<button class="btn small" id="selexit" title="Salir de la selección">${icon('x')}</button>`
$('#selrun')?.addEventListener('click', runHd)
$('#selexit')?.addEventListener('click', () => setForm(null))
}
}
async function runHd() {
const ids = formIds()
if (!ids.length) return
try {
const u = await api(`/api/upscale/${id}`, { method: 'POST', body: { chapterIds: ids } })
toast(`${ids.length} capítulo${ids.length === 1 ? '' : 's'} en cola para HD`)
picked.clear()
setForm(null)
renderHd(u)
} catch (err) { toast(err.message) }
}
function setForm(mode) {
const wasPick = hdForm === 'pick'
hdForm = mode
renderHdForm()
if (wasPick !== (mode === 'pick')) renderList()
updateSummary()
}
function renderHdForm() {
const box = $('#hdform')
if (!box) return
box.hidden = !hdForm
if (!hdForm) { box.innerHTML = ''; return }
const dls = chapters.filter(c => dlSet.has(c.id))
const cand = hdCandidates()
const opt = (c, sel) => `<option value="${c.id}" ${sel ? 'selected' : ''}>${esc(c.num ? `Cap. ${c.num}` : 'Oneshot')}${hdSet.has(c.id) ? ' (HD)' : ''}</option>`
const from = cand[0] || dls[0], to = cand[Math.min(cand.length, 10) - 1] || dls.at(-1)
box.innerHTML = `
<div class="seg" role="tablist">
<button data-hm="pending" class="${hdForm === 'pending' ? 'on' : ''}">Pendientes</button>
<button data-hm="range" class="${hdForm === 'range' ? 'on' : ''}">Rango</button>
<button data-hm="pick" class="${hdForm === 'pick' ? 'on' : ''}">Elegir</button>
</div>
${hdForm === 'range' ? `<div class="row range"><label>Desde <select id="hdfrom">${dls.map(c => opt(c, c === from)).join('')}</select></label>
<label>hasta <select id="hdto">${dls.map(c => opt(c, c === to)).join('')}</select></label></div>` : ''}
${hdForm === 'pick' ? '<div class="note muted">Marca los capítulos en la lista de abajo. Mayús + clic marca un tramo entero.</div>' : ''}
${hdForm === '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="hdsum" class="sum"></b></div>
<div class="psec-actions"><button class="btn small primary" id="hdrun">${icon('sparkles')} Reescalar</button><button class="btn small" id="hdclose">Cerrar</button></div>`
box.querySelectorAll('[data-hm]').forEach(b => b.addEventListener('click', () => setForm(b.dataset.hm)))
$('#hdfrom')?.addEventListener('change', updateSummary)
$('#hdto')?.addEventListener('change', updateSummary)
$('#hdclose').addEventListener('click', () => setForm(null))
$('#hdrun').addEventListener('click', async () => {
if (hdForm === 'pending' && $('#hdautoform')?.checked !== !!hdInfo?.auto) await api(`/api/upscale/${id}/auto`, { method: 'POST', body: { auto: $('#hdautoform').checked } }).catch(() => {})
runHd()
})
}
const renderHd = u => {
const box = $('#hd')
if (!box) return
@@ -517,17 +578,17 @@ async function viewManga(id, langOverride) {
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 = hdCandidates().length > 0
const canAdd = KINDS.hd.candidates().length > 0
const actions = [
canAdd ? `<button class="btn small ${hdForm ? 'on' : ''}" id="hdopen" aria-expanded="${!!hdForm}">${icon('sparkles')} ${active ? 'Añadir capítulos…' : 'Reescalar…'}</button>` : '',
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('')
// El formulario se conserva entre sondeos: solo se repinta la parte de arriba.
// 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', () => setForm(hdForm ? null : 'pending'))
$('#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' }))
@@ -541,10 +602,8 @@ async function viewManga(id, langOverride) {
}
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) { renderList(); if (hdForm && hdForm !== 'pick') renderHdForm(); updateSummary() }
if (hdForm && !hdCandidates().length) setForm(null)
if (changed) refreshSel()
}
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer) }
const renderFollow = () => {
const b = $('#follow')
@@ -576,21 +635,22 @@ async function viewManga(id, langOverride) {
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> ` : '')
+ (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> ` : '')
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
function renderList() {
if (!ul) return
const list = desc ? [...chapters].reverse() : chapters
const pickable = new Set(hdForm === 'pick' ? hdCandidates().map(c => c.id) : [])
ul.classList.toggle('picking', hdForm === 'pick')
const picking = sel?.mode === 'pick'
const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
ul.classList.toggle('picking', picking)
ul.innerHTML = list.map((c, i) => {
const r = readMap.get(c.id)
const done = r && r.total_pages && r.page >= r.total_pages - 1
if (hdForm === 'pick') {
if (picking) {
const ok = pickable.has(c.id)
return `<li><label class="pick ${ok ? '' : 'off'}" title="${ok ? '' : dlSet.has(c.id) ? 'Ya está en HD o en cola' : 'No está descargado'}">
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>`
}
@@ -598,7 +658,7 @@ async function viewManga(id, langOverride) {
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
}).join('')
}
// Selección para HD (Mayús + clic marca el tramo desde el último marcado).
// 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
@@ -613,8 +673,8 @@ async function viewManga(id, langOverride) {
lastPick = i
updateSummary()
})
renderDl(download)
renderList()
renderDl(download)
renderHd(upscale)
$('#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) })