Colas por usuario y sin pantalla negra mientras carga la página siguiente

- Descargas y reescalados tienen usuarios (download_users): Colas y la biblioteca descargada muestran
  solo lo de cada uno. Un manga ya descargado por otro se lee desde el disco y entra en tu biblioteca
  al empezar a leerlo; borrar uno compartido solo lo quita de la tuya. Lo anterior se asigna a quien lo
  lee, sigue o tiene en una lista (o al primer usuario).
- Lector: si la página siguiente no ha llegado, la actual se queda con un indicador hasta que llega
  (máx. 4 s); si no, la nueva aparece con fundido y un indicador en vez de negro. Precarga de 6
  páginas por delante, ya decodificadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 16:20:09 -06:00
co-authored by Claude Opus 5.5
parent 84a60a6501
commit 09354f675e
4 changed files with 114 additions and 20 deletions
+61 -12
View File
@@ -905,9 +905,10 @@ async function viewManga(id, langOverride) {
$('#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
await api(`/api/download/${id}`, { method: 'DELETE' })
toast('Copia local eliminada')
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
@@ -1412,24 +1413,67 @@ async function viewReader(mangaId, chapterId, startPage) {
}
return [i]
}
// Las 4 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso).
// 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 load = i => {
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 = () => isWide(im) && wide.add(i)
im.src = pages[i]
im.decode?.().catch(() => {})
preloaded.set(i, im)
}
for (let k = 1; k <= 4 && shown.at(-1) + k < total; k++) load(shown.at(-1) + k)
for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) load(shown[0] - k)
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
}
const imgReady = im => im.complete && im.naturalWidth > 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 old = $('.stage', box)
const stage = document.createElement('div')
stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
const imgs = [...stage.querySelectorAll('img')]
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
const 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) presentPaged(stage, dir)
})
return
}
presentPaged(stage, dir)
}
function presentPaged(stage, dir) {
const old = $('.stage', box)
// Lo que aún no ha llegado se ve con un indicador de carga y aparece con un fundido, no de golpe.
const imgs = [...stage.querySelectorAll('img')]
const pending = imgs.filter(im => !imgReady(im))
pending.forEach(im => {
im.classList.add('pending')
const done = () => {
im.classList.remove('pending')
if (!stage.querySelector('img.pending')) stage.classList.remove('loading')
}
im.addEventListener('load', done, { once: true })
im.addEventListener('error', done, { once: true })
})
stage.classList.toggle('loading', pending.length > 0)
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir))
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
@@ -1672,6 +1716,9 @@ async function viewReader(mangaId, chapterId, startPage) {
}
function showEnd() {
finishTurn()
showSeq++ // descarta una página que estuviera esperando a cargar
clearTimeout(waitTimer)
box.classList.remove('waiting')
atEnd = true
resetZoom()
box.innerHTML = endBlock
@@ -2468,8 +2515,8 @@ async function viewQueues() {
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}" title="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 has descargado nada. Usa “Descargar” en la ficha de un manga.'}</p>`}
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')
@@ -2486,8 +2533,10 @@ async function viewQueues() {
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
act(() => api(`/api/download/${b.dataset.del}`, { method: 'DELETE' }), 'Copia local eliminada')
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 {
+8 -1
View File
@@ -101,7 +101,14 @@ input:focus, select:focus { border-color: var(--accent); }
.rbar select { padding: 5px 8px; }
.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); }
#pages img { filter: var(--page-filter, none); transition: filter .2s; }
#pages img { filter: var(--page-filter, none); transition: filter .2s, opacity .25s; }
/* Página que aún no ha llegado: indicador de carga y fundido al aparecer */
#pages .stage img.pending { opacity: 0; }
.paged.waiting::after, .paged .stage.loading::after { content: ''; position: absolute; left: 50%; top: 50%; width: 34px; height: 34px;
margin: -17px 0 0 -17px; border-radius: 50%; border: 3px solid rgba(255,255,255,.18); border-top-color: var(--accent);
animation: pgspin .8s linear infinite; z-index: 6; pointer-events: none; }
.paged .stage.loading { min-height: 100%; }
@keyframes pgspin { to { transform: rotate(360deg); } }
.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; }
.paged img { max-width: 100%; max-height: 100%; object-fit: contain; }
.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; }