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:
+173
-113
@@ -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) })
|
||||
|
||||
@@ -160,6 +160,7 @@ a.tag:hover, .tag.on { background: var(--accent); color: var(--accent-ink); }
|
||||
|
||||
/* Insignias y botón Seguir */
|
||||
.hd { display: inline-block; font-size: .65rem; font-weight: 800; letter-spacing: .03em; padding: 0 5px; border-radius: 4px; border: 1px solid var(--accent); color: var(--accent); vertical-align: middle; }
|
||||
.chapters .meta .queued { opacity: .55; }
|
||||
.hd.pending { border-style: dashed; color: var(--muted); border-color: var(--muted); }
|
||||
.badge { display: inline-block; font-size: .72rem; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-ink); vertical-align: middle; }
|
||||
.btn.on { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
Reference in New Issue
Block a user