Estante 3D: mueble, lomos, logos y vista previa de tomos
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
|||||||
|
// Detector de rostros de anime, en un Worker para no trabar el estante.
|
||||||
|
// Evalúa en JavaScript la cascada LBP «lbpcascade_animeface» de nagadomi: https://github.com/nagadomi/lbpcascade_animeface
|
||||||
|
// La cascada (animeface-cascade.json) es MIT, (C) 2011 nagadomi@nurs.or.jp; el aviso completo va en el propio archivo.
|
||||||
|
const integral = (gray, w, h) => {
|
||||||
|
const I = new Int32Array((w + 1) * (h + 1))
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
let row = 0
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
row += gray[y * w + x]
|
||||||
|
I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return I
|
||||||
|
}
|
||||||
|
function equalize(gray) {
|
||||||
|
const hist = new Int32Array(256), n = gray.length
|
||||||
|
for (let i = 0; i < n; i++) hist[gray[i]]++
|
||||||
|
let i = 0
|
||||||
|
while (i < 255 && !hist[i]) i++
|
||||||
|
const out = new Uint8Array(n), first = hist[i]
|
||||||
|
if (first === n) return out.fill(i)
|
||||||
|
const scale = 255 / (n - first), lut = new Uint8Array(256)
|
||||||
|
let sum = 0
|
||||||
|
for (i++; i < 256; i++) { sum += hist[i]; lut[i] = Math.max(0, Math.min(255, Math.round(sum * scale))) }
|
||||||
|
for (let k = 0; k < n; k++) out[k] = lut[gray[k]]
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
function shrink(gray, w, h, W, H) { // promedio por área, como INTER_LINEAR al reducir poco
|
||||||
|
const out = new Uint8Array(W * H), fx = w / W, fy = h / H
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const sy = (y + 0.5) * fy - 0.5, y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(h - 1, y0 + 1), ty = sy - Math.floor(sy)
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const sx = (x + 0.5) * fx - 0.5, x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(w - 1, x0 + 1), tx = sx - Math.floor(sx)
|
||||||
|
const a = gray[y0 * w + x0] * (1 - tx) + gray[y0 * w + x1] * tx, b = gray[y1 * w + x0] * (1 - tx) + gray[y1 * w + x1] * tx
|
||||||
|
out[y * W + x] = a * (1 - ty) + b * ty + 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
function scan(cascade, gray, W, H, step, emit) {
|
||||||
|
const I = integral(gray, W, H), S = W + 1, { stages, features, w: cw, h: ch } = cascade
|
||||||
|
for (let wy = 0; wy + ch <= H; wy += step) {
|
||||||
|
for (let wx = 0; wx + cw <= W; wx += step) {
|
||||||
|
let pass = true
|
||||||
|
for (let s = 0; s < stages.length && pass; s++) {
|
||||||
|
const [limit, weak] = stages[s]
|
||||||
|
let sum = 0
|
||||||
|
for (let q = 0; q < weak.length; q++) {
|
||||||
|
const [f, subset, leaf] = weak[q], r = features[f], fx = wx + r[0], fy = wy + r[1], bw = r[2], bh = r[3]
|
||||||
|
const a = fy * S + fx, y1 = a + bh * S, y2 = a + 2 * bh * S, y3 = a + 3 * bh * S
|
||||||
|
const p0 = I[a], p1 = I[a + bw], p2 = I[a + 2 * bw], p3 = I[a + 3 * bw]
|
||||||
|
const p4 = I[y1], p5 = I[y1 + bw], p6 = I[y1 + 2 * bw], p7 = I[y1 + 3 * bw]
|
||||||
|
const p8 = I[y2], p9 = I[y2 + bw], p10 = I[y2 + 2 * bw], p11 = I[y2 + 3 * bw]
|
||||||
|
const p12 = I[y3], p13 = I[y3 + bw], p14 = I[y3 + 2 * bw], p15 = I[y3 + 3 * bw]
|
||||||
|
const c = p10 - p9 - p6 + p5
|
||||||
|
const code = ((p5 - p4 - p1 + p0 >= c) << 7) | ((p6 - p5 - p2 + p1 >= c) << 6) | ((p7 - p6 - p3 + p2 >= c) << 5) | ((p11 - p10 - p7 + p6 >= c) << 4) | ((p15 - p14 - p11 + p10 >= c) << 3) | ((p14 - p13 - p10 + p9 >= c) << 2) | ((p13 - p12 - p9 + p8 >= c) << 1) | (p9 - p8 - p5 + p4 >= c)
|
||||||
|
sum += leaf[subset[code >> 5] & (1 << (code & 31)) ? 0 : 1]
|
||||||
|
}
|
||||||
|
if (sum < limit) pass = false
|
||||||
|
}
|
||||||
|
if (pass) emit(wx, wy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Agrupa ventanas parecidas, como groupRectangles de OpenCV.
|
||||||
|
function group(rects, minNeighbors, eps = 0.2) {
|
||||||
|
const parent = rects.map((_, i) => i), find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])))
|
||||||
|
const close = (a, b) => { const d = eps * 0.5 * (Math.min(a.w, b.w) + Math.min(a.h, b.h)); return Math.abs(a.x - b.x) <= d && Math.abs(a.y - b.y) <= d && Math.abs(a.x + a.w - b.x - b.w) <= d && Math.abs(a.y + a.h - b.y - b.h) <= d }
|
||||||
|
for (let i = 0; i < rects.length; i++) for (let j = 0; j < i; j++) if (close(rects[i], rects[j])) parent[find(i)] = find(j)
|
||||||
|
const by = new Map()
|
||||||
|
rects.forEach((r, i) => { const k = find(i); const g = by.get(k) || { x: 0, y: 0, w: 0, h: 0, n: 0 }; g.x += r.x; g.y += r.y; g.w += r.w; g.h += r.h; g.n++; by.set(k, g) })
|
||||||
|
const out = [...by.values()].filter(g => g.n > minNeighbors).map(g => ({ x: Math.round(g.x / g.n), y: Math.round(g.y / g.n), w: Math.round(g.w / g.n), h: Math.round(g.h / g.n), n: g.n }))
|
||||||
|
// Quita los que quedan dentro de otro mayor con más apoyos.
|
||||||
|
return out.filter(a => !out.some(b => b !== a && b.n >= a.n && a.x >= b.x - b.w * 0.2 && a.y >= b.y - b.h * 0.2 && a.x + a.w <= b.x + b.w * 1.2 && a.y + a.h <= b.y + b.h * 1.2 && b.w > a.w))
|
||||||
|
}
|
||||||
|
/** data: RGBA. Devuelve los rostros en píxeles de la imagen, con `n` = ventanas que los apoyan (más es más fiable). */
|
||||||
|
function detectFaces(cascade, data, w, h, { scale = 1.1, minNeighbors = 5, minSize = 24 } = {}) {
|
||||||
|
const raw = new Uint8Array(w * h)
|
||||||
|
for (let i = 0; i < raw.length; i++) raw[i] = data[i * 4] * 0.299 + data[i * 4 + 1] * 0.587 + data[i * 4 + 2] * 0.114 + 0.5
|
||||||
|
const gray = equalize(raw), rects = []
|
||||||
|
for (let f = 1; ; f *= scale) {
|
||||||
|
const size = Math.round(cascade.w * f)
|
||||||
|
const W = Math.round(w / f), H = Math.round(h / f)
|
||||||
|
if (W < cascade.w || H < cascade.h) break
|
||||||
|
if (size < minSize) continue
|
||||||
|
scan(cascade, f === 1 ? gray : shrink(gray, w, h, W, H), W, H, f > 2 ? 1 : 2, (x, y) => rects.push({ x: Math.round(x * f), y: Math.round(y * f), w: size, h: size }))
|
||||||
|
}
|
||||||
|
return group(rects, minNeighbors)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cascade = null
|
||||||
|
self.onmessage = async ({ data: { id, pixels, w, h } }) => {
|
||||||
|
try {
|
||||||
|
cascade ||= await fetch(`/animeface-cascade.json${self.location.search}`).then(r => { if (!r.ok) throw new Error(r.status); return r.json() })
|
||||||
|
self.postMessage({ id, faces: detectFaces(cascade, new Uint8ClampedArray(pixels), w, h, { minNeighbors: 3 }) })
|
||||||
|
} catch (e) { self.postMessage({ id, faces: null }) }
|
||||||
|
}
|
||||||
+123
-5
@@ -364,6 +364,7 @@ window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); instal
|
|||||||
|
|
||||||
// ---------- Router ----------
|
// ---------- Router ----------
|
||||||
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
|
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
|
||||||
|
let shelfTrip = null // { mangaId } mientras se lee algo abierto desde el estante: libro 3D y vuelta al estante
|
||||||
async function router() {
|
async function router() {
|
||||||
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
|
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
|
||||||
if (cleanup) { cleanup(); cleanup = null }
|
if (cleanup) { cleanup(); cleanup = null }
|
||||||
@@ -371,6 +372,7 @@ async function router() {
|
|||||||
const [pathPart, query] = location.hash.slice(1).split('?')
|
const [pathPart, query] = location.hash.slice(1).split('?')
|
||||||
const parts = (pathPart || '/').split('/').filter(Boolean)
|
const parts = (pathPart || '/').split('/').filter(Boolean)
|
||||||
const params = new URLSearchParams(query || '')
|
const params = new URLSearchParams(query || '')
|
||||||
|
if (parts[0] !== 'read' && parts[0] !== 'shelf') shelfTrip = null
|
||||||
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
|
document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`))
|
||||||
|
|
||||||
if (!me) {
|
if (!me) {
|
||||||
@@ -386,6 +388,7 @@ async function router() {
|
|||||||
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
|
||||||
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
|
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
|
||||||
if (parts[0] === 'library') return await viewLibrary(params)
|
if (parts[0] === 'library') return await viewLibrary(params)
|
||||||
|
if (parts[0] === 'shelf') return await viewShelf()
|
||||||
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
|
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
|
||||||
if (parts[0] === 'queues') return await viewQueues()
|
if (parts[0] === 'queues') return await viewQueues()
|
||||||
if (parts[0] === 'stats') return await viewStats()
|
if (parts[0] === 'stats') return await viewStats()
|
||||||
@@ -2102,7 +2105,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="reader">
|
<div class="reader">
|
||||||
<div class="rbar top">
|
<div class="rbar top">
|
||||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
<a class="btn small" href="${shelfTrip?.mangaId === mangaId ? '#/shelf' : `#/manga/${mangaId}`}" title="${shelfTrip?.mangaId === mangaId ? 'Volver al estante' : 'Volver a la ficha'}">${icon('back')}</a>
|
||||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||||
<button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
<button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||||||
@@ -3067,7 +3070,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
|
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
|
||||||
const b3dWanted = () => B3D_OK && !!settings.book3d && isPaged() && settings.fit !== 'width'
|
const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
|
||||||
|
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
|
||||||
const b3dOn = () => b3dWanted() && !atEnd
|
const b3dOn = () => b3dWanted() && !atEnd
|
||||||
const b3dPaper = () => {
|
const b3dPaper = () => {
|
||||||
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
|
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
|
||||||
@@ -3817,6 +3821,120 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="car
|
|||||||
<div class="t">${esc(title)}</div>
|
<div class="t">${esc(title)}</div>
|
||||||
${sub ? `<div class="sub">${sub}</div>` : ''}
|
${sub ? `<div class="sub">${sub}</div>` : ''}
|
||||||
</a>`
|
</a>`
|
||||||
|
// ---------- Estante 3D ----------
|
||||||
|
const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles
|
||||||
|
// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado.
|
||||||
|
function handoffCover(src) {
|
||||||
|
document.querySelector('.handoff')?.remove()
|
||||||
|
const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' })
|
||||||
|
document.body.append(img)
|
||||||
|
const t0 = performance.now()
|
||||||
|
const fade = () => { img.classList.add('out'); setTimeout(() => img.remove(), 450) }
|
||||||
|
const wait = () => {
|
||||||
|
if (!img.isConnected) return
|
||||||
|
const ready = document.querySelector('#b3d:not([hidden]) canvas')
|
||||||
|
if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante
|
||||||
|
if (performance.now() - t0 > 2500 || !location.hash.startsWith('#/read/')) return fade()
|
||||||
|
setTimeout(wait, 60)
|
||||||
|
}
|
||||||
|
wait()
|
||||||
|
}
|
||||||
|
async function viewShelf() {
|
||||||
|
if (!SHELF_OK) {
|
||||||
|
app.innerHTML = `<h1>Estante 3D</h1><p class="muted">Este navegador no tiene WebGL2.</p><p><a class="btn" href="#/library">${icon('library')} Volver a la librería</a></p>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document.body.classList.add('reading') // sin la barra de arriba
|
||||||
|
app.innerHTML = `<div id="shelf3d"></div><a class="btn small shelfback" href="#/library">${icon('back')} Librería</a><p class="shelfnote" id="shelfnote">Colocando los tomos…</p>
|
||||||
|
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
|
||||||
|
const note = $('#shelfnote'), card = $('#shelfpeek')
|
||||||
|
const focusOn = shelfTrip?.mangaId || null // al volver del lector, con ese tomo a la vista
|
||||||
|
shelfTrip = null
|
||||||
|
let shelf = null, gone = false
|
||||||
|
const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); shelf?.destroy(); shelf = null }
|
||||||
|
// Vistazo: lo que se sabe del tomo sacado, junto a él.
|
||||||
|
const readOf = info => {
|
||||||
|
const { work, vol, state } = info
|
||||||
|
if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0)
|
||||||
|
if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0])
|
||||||
|
return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}`
|
||||||
|
}
|
||||||
|
// A la mesa: el estante posa el tomo como el lector lo mostrará y una foto del último fotograma tapa el cambio.
|
||||||
|
let leaving = false
|
||||||
|
const toTable = info => {
|
||||||
|
if (leaving) return
|
||||||
|
const href = readOf(info)
|
||||||
|
const go = snap => {
|
||||||
|
if (gone) return
|
||||||
|
if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id }
|
||||||
|
if (snap) handoffCover(snap)
|
||||||
|
location.hash = href
|
||||||
|
}
|
||||||
|
leaving = true
|
||||||
|
if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null)
|
||||||
|
}
|
||||||
|
const showPeek = info => {
|
||||||
|
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
|
||||||
|
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
|
||||||
|
const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ')
|
||||||
|
const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0
|
||||||
|
const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar'
|
||||||
|
card.innerHTML = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
|
||||||
|
<h2 class="ptitle">${esc(work.title)}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
|
||||||
|
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
|
||||||
|
<div class="pacts"><a class="btn primary" id="peekread" href="${readOf(info)}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a><a class="btn" href="#/manga/${work.id}">Ver ficha</a></div>`
|
||||||
|
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
|
||||||
|
card.querySelector('#peekread').addEventListener('click', e => { e.preventDefault(); toTable(info) })
|
||||||
|
clearTimeout(card.hideTimer)
|
||||||
|
card.hidden = false
|
||||||
|
requestAnimationFrame(() => card.classList.add('on'))
|
||||||
|
}
|
||||||
|
const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || ''
|
||||||
|
try {
|
||||||
|
const [{ works }, lib, mod] = await Promise.all([api('/api/shelf'), import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)])
|
||||||
|
if (gone) return
|
||||||
|
if (!works.length) { note.textContent = 'Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.'; return }
|
||||||
|
shelf = mod.createShelf3D($('#shelf3d'), lib, {
|
||||||
|
works,
|
||||||
|
still: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
|
onPeek: showPeek,
|
||||||
|
onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa
|
||||||
|
table: me?.tableV ? `/api/table?v=${me.tableV}` : null,
|
||||||
|
// Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos.
|
||||||
|
pagesOf: chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []),
|
||||||
|
})
|
||||||
|
if (!shelf) throw new Error('sin WebGL2')
|
||||||
|
if (focusOn) shelf.focus(focusOn)
|
||||||
|
// Los tomos llegan aparte, de pocos en pocos: la primera vez pueden tardar por las fuentes externas.
|
||||||
|
let next = 0, pending = works.length
|
||||||
|
const worker = async () => {
|
||||||
|
while (next < works.length && !gone) {
|
||||||
|
const w = works[next++]
|
||||||
|
const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
|
||||||
|
if (gone) return
|
||||||
|
if (r?.volumes?.length) shelf.setVolumes(w.id, r.volumes)
|
||||||
|
if (--pending === 0) note.hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 3; k++) worker()
|
||||||
|
// Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa.
|
||||||
|
let nextLogo = 0
|
||||||
|
const logoWorker = async () => {
|
||||||
|
while (nextLogo < works.length && !gone) {
|
||||||
|
const w = works[nextLogo++]
|
||||||
|
const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null)
|
||||||
|
if (gone) return
|
||||||
|
if (r?.logo) shelf.setLogo(w.id, r.logo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = 0; k < 2; k++) logoWorker()
|
||||||
|
} catch (e) {
|
||||||
|
if (!gone) note.textContent = `No se pudo abrir el estante: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function viewLibrary(params) {
|
async function viewLibrary(params) {
|
||||||
const tab = params.get('tab') || 'reading'
|
const tab = params.get('tab') || 'reading'
|
||||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||||
@@ -3837,7 +3955,7 @@ async function viewLibrary(params) {
|
|||||||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
|
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
|
||||||
]
|
]
|
||||||
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
|
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
|
||||||
app.innerHTML = `<h1>Librería</h1>
|
app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('library')} Estante 3D</a>` : ''}</div>
|
||||||
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
|
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
|
||||||
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
|
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
|
||||||
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
|
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
|
||||||
@@ -4046,7 +4164,7 @@ async function viewQueues() {
|
|||||||
</section>
|
</section>
|
||||||
<section class="qsec">
|
<section class="qsec">
|
||||||
<h2>${icon('book')} Biblioteca descargada</h2>
|
<h2>${icon('book')} Biblioteca descargada</h2>
|
||||||
<p class="muted small">CBZ en <code>/zdata/media/mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
<p class="muted small">CBZ en <code>./mangas</code> (compatibles con Kavita, Komga, Mihon…); se leen desde el disco.${list.length ? ` Total: <b>${fmtSize(total)}</b>.` : ''}</p>
|
||||||
${library.length ? `<div class="grid">${library.map(d => qcard({
|
${library.length ? `<div class="grid">${library.map(d => qcard({
|
||||||
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
|
key: `lib:${d.manga_id}`, id: d.manga_id, cover: d.cover, title: d.title,
|
||||||
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
|
pct: d.total ? Math.min(100, (d.done / d.total) * 100) : null,
|
||||||
@@ -4269,7 +4387,7 @@ function viewSettings() {
|
|||||||
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
|
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
|
||||||
<form id="notify" style="display:grid;gap:8px">
|
<form id="notify" style="display:grid;gap:8px">
|
||||||
<b style="font-size:.9rem">ntfy</b>
|
<b style="font-size:.9rem">ntfy</b>
|
||||||
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://192.168.1.141:4546" autocomplete="off">
|
<input type="text" name="ntfyUrl" placeholder="Servidor, p. ej. http://localhost:4546" autocomplete="off">
|
||||||
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
|
<input type="text" name="ntfyTopic" placeholder="Tema, p. ej. mangas" autocomplete="off">
|
||||||
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
|
<input type="password" name="ntfyToken" placeholder="Token (tk_…) o usuario:contraseña" autocomplete="new-password">
|
||||||
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
|
<b style="font-size:.9rem">Telegram <span class="muted" style="font-weight:normal">(opcional)</span></b>
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ export function fitCamera(points, aspect, marginX, marginY) {
|
|||||||
// Colores de la portada para la contraportada y sus botones.
|
// Colores de la portada para la contraportada y sus botones.
|
||||||
const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
|
const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) }
|
||||||
const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
|
const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
|
||||||
const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05])
|
export const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05])
|
||||||
const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t)
|
const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t)
|
||||||
const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})`
|
export const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})`
|
||||||
// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen.
|
// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen.
|
||||||
export function coverPalette(data) {
|
export function coverPalette(data) {
|
||||||
const boxes = new Map()
|
const boxes = new Map()
|
||||||
@@ -143,7 +143,7 @@ export function coverPalette(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Madera generada: se repite sin que se note el corte.
|
// Madera generada: se repite sin que se note el corte.
|
||||||
function woodCanvas() {
|
export function woodCanvas() {
|
||||||
const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d')
|
const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d')
|
||||||
let seed = 7
|
let seed = 7
|
||||||
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
|
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
|
||||||
@@ -243,7 +243,7 @@ void main() {
|
|||||||
}`
|
}`
|
||||||
|
|
||||||
// Junta rejillas en una malla: pos(3) normal(3) uv(2).
|
// Junta rejillas en una malla: pos(3) normal(3) uv(2).
|
||||||
class Shape {
|
export class Shape {
|
||||||
constructor() { this.v = []; this.i = [] }
|
constructor() { this.v = []; this.i = [] }
|
||||||
grid(cols, rows, at) {
|
grid(cols, rows, at) {
|
||||||
const base = this.v.length / 8
|
const base = this.v.length / 8
|
||||||
@@ -305,8 +305,9 @@ function rimShape(pts, y0, y1, thick) {
|
|||||||
|
|
||||||
// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo.
|
// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo.
|
||||||
// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados.
|
// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados.
|
||||||
const MAX_DEG = 15, FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
|
export const MAX_DEG = 15
|
||||||
function createTilt(onChange) {
|
const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
|
||||||
|
export function createTilt(onChange) {
|
||||||
const target = { x: 0, y: 0 }
|
const target = { x: 0, y: 0 }
|
||||||
let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
|
let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
|
||||||
const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
|
const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Fuentes manuscritas de las notas del estante, de Google Fonts (solo el subconjunto latino):
|
||||||
|
Caveat, Patrick Hand, Kalam, Gochi Hand, Indie Flower y Shadows Into Light: SIL Open Font License 1.1 (https://openfontlicense.org).
|
||||||
|
Permanent Marker: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="sw-version" content="">
|
<meta name="sw-version" content="">
|
||||||
<meta name="b3d-version" content="">
|
<meta name="b3d-version" content="">
|
||||||
|
<meta name="shelf-version" content="">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#14121a">
|
<meta name="theme-color" content="#14121a">
|
||||||
<title>Manga Reader</title>
|
<title>Manga Reader</title>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -460,6 +460,11 @@ body.reading #netbanner { display: none; }
|
|||||||
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
|
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
|
||||||
.chapters .meta .cdate.approx { opacity: .7; }
|
.chapters .meta .cdate.approx { opacity: .7; }
|
||||||
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
|
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
|
||||||
|
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
|
||||||
|
#shelf3d canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.shelfback { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); }
|
||||||
|
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
|
||||||
|
.libhead { justify-content: space-between; align-items: center; }
|
||||||
#b3d { position: fixed; inset: 0; z-index: 0; }
|
#b3d { position: fixed; inset: 0; z-index: 0; }
|
||||||
#b3d[hidden] { display: none; }
|
#b3d[hidden] { display: none; }
|
||||||
#b3d canvas { display: block; width: 100%; height: 100%; }
|
#b3d canvas { display: block; width: 100%; height: 100%; }
|
||||||
@@ -481,3 +486,19 @@ body.reading #netbanner { display: none; }
|
|||||||
.reader.b3d.b3dcrop { background: #000; }
|
.reader.b3d.b3dcrop { background: #000; }
|
||||||
.reader.b3d.b3dcrop > #b3d { display: none; }
|
.reader.b3d.b3dcrop > #b3d { display: none; }
|
||||||
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
|
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
|
||||||
|
/* Vistazo del estante: junto al tomo sacado, a la derecha en apaisado y abajo en vertical */
|
||||||
|
.shelfpeek { position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(38vw, 380px); transform: translate(12px, -50%); opacity: 0; transition: opacity .22s, transform .22s; padding: 18px 18px 16px; border-radius: 14px; background: rgb(20 14 10 / .78); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); color: #f4ede4; box-shadow: 0 10px 30px rgb(0 0 0 / .4); }
|
||||||
|
.shelfpeek.on { opacity: 1; transform: translate(0, -50%); }
|
||||||
|
.shelfpeek .ptitle { margin: 0 34px 4px 0; font-size: 1.2rem; line-height: 1.25; }
|
||||||
|
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; color: #cdbfae; font-size: .9rem; }
|
||||||
|
.shelfpeek .pbar { height: 4px; margin: 12px 0 6px; border-radius: 2px; background: rgb(255 255 255 / .16); overflow: hidden; }
|
||||||
|
.shelfpeek .pbar i { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||||
|
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; }
|
||||||
|
.shelfpeek .peekx { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / .1); color: inherit; cursor: pointer; }
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.shelfpeek { left: calc(12px + env(safe-area-inset-left)); right: calc(12px + env(safe-area-inset-right)); top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); width: auto; transform: translateY(12px); }
|
||||||
|
.shelfpeek.on { transform: none; }
|
||||||
|
}
|
||||||
|
.handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; }
|
||||||
|
.handoff.out { opacity: 0; }
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
|
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
|
||||||
const SHELL = 'shell-v1'
|
const SHELL = 'shell-v1'
|
||||||
const OFFLINE = 'offline-v1'
|
const OFFLINE = 'offline-v1'
|
||||||
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
||||||
|
|
||||||
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
|
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
|
||||||
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
|
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
|
||||||
@@ -21,7 +21,7 @@ self.addEventListener('install', e => {
|
|||||||
})
|
})
|
||||||
self.addEventListener('activate', e => {
|
self.addEventListener('activate', e => {
|
||||||
e.waitUntil(caches.keys()
|
e.waitUntil(caches.keys()
|
||||||
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE).map(k => caches.delete(k))))
|
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
|
||||||
.then(() => self.clients.claim()))
|
.then(() => self.clients.claim()))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -17,6 +17,7 @@ import { setupConnectivity } from './connectivity.js'
|
|||||||
import { setupMeta } from './meta.js'
|
import { setupMeta } from './meta.js'
|
||||||
import { setupVolumes } from './volumes.js'
|
import { setupVolumes } from './volumes.js'
|
||||||
import { setupCredits } from './credits.js'
|
import { setupCredits } from './credits.js'
|
||||||
|
import { setupShelf } from './shelf.js'
|
||||||
import { setupBooks, isBookId } from './books.js'
|
import { setupBooks, isBookId } from './books.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3352)
|
const PORT = Number(process.env.PORT || 3352)
|
||||||
@@ -834,7 +835,7 @@ const requireUser = req => {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
|
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.json': 'application/json', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
|
||||||
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
|
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
|
||||||
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
|
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
|
||||||
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
|
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
|
||||||
@@ -843,6 +844,7 @@ const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
|
|||||||
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
|
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
|
||||||
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
|
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
|
||||||
.replace('<meta name="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
|
.replace('<meta name="b3d-version" content="">', `<meta name="b3d-version" content="${assetVersion('book3d.js')}">`)
|
||||||
|
.replace('<meta name="shelf-version" content="">', `<meta name="shelf-version" content="${assetVersion('shelf3d.js')}${assetVersion('animeface.js')}">`)
|
||||||
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
|
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
|
||||||
function serveStatic(res, pathname) {
|
function serveStatic(res, pathname) {
|
||||||
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
||||||
@@ -924,6 +926,7 @@ async function route(req, res) {
|
|||||||
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
|
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
|
||||||
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
||||||
if ((await credits.route(p, method, req, res, user)) !== false) return
|
if ((await credits.route(p, method, req, res, user)) !== false) return
|
||||||
|
if (shelf.route(p, method, res, user) !== false) return
|
||||||
if ((await upscale.route(p, method, req, res)) !== false) {
|
if ((await upscale.route(p, method, req, res)) !== false) {
|
||||||
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
|
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
|
||||||
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
|
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
|
||||||
@@ -996,6 +999,9 @@ async function route(req, res) {
|
|||||||
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||||
return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
|
return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) })
|
||||||
}
|
}
|
||||||
|
if ((m = /^\/api\/logo\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||||||
|
return json(res, 200, { logo: isBookId(m[1]) ? null : await volumes.logo(m[1], lang).catch(() => null) })
|
||||||
|
}
|
||||||
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
|
if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') {
|
||||||
let vol
|
let vol
|
||||||
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
|
try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') }
|
||||||
@@ -1170,6 +1176,7 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_
|
|||||||
net.whenOnline(() => pumpDownloads())
|
net.whenOnline(() => pumpDownloads())
|
||||||
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
|
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
|
||||||
const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
|
const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
|
||||||
|
const shelf = setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS })
|
||||||
const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
|
const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net })
|
||||||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Estante 3D: las obras de un usuario que pueden ir en el mueble. Los tomos de cada una se piden aparte (/api/volumes), porque pueden tardar.
|
||||||
|
import { hasNoVolumes } from './volumes.js'
|
||||||
|
|
||||||
|
export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) {
|
||||||
|
function works(userId) {
|
||||||
|
const out = new Map()
|
||||||
|
const add = (id, fields) => {
|
||||||
|
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null }
|
||||||
|
w.title ||= fields.title || ''
|
||||||
|
w.cover ||= fields.cover || null
|
||||||
|
w.last = Math.max(w.last, fields.at || 0)
|
||||||
|
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null })
|
||||||
|
out.set(id, w)
|
||||||
|
}
|
||||||
|
for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) {
|
||||||
|
add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at })
|
||||||
|
}
|
||||||
|
for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) {
|
||||||
|
add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })
|
||||||
|
}
|
||||||
|
for (const r of db.prepare(`SELECT manga_id, title, folder, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS}`).all(userId)) {
|
||||||
|
add(r.manga_id, { title: r.title, cover: downloadCover(r.manga_id, r.folder, r.meta_cover), at: r.updated_at })
|
||||||
|
}
|
||||||
|
for (const r of db.prepare('SELECT i.manga_id, i.title, i.cover, i.added_at FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?').all(userId)) {
|
||||||
|
add(r.manga_id, { title: r.title, cover: r.cover, at: r.added_at })
|
||||||
|
}
|
||||||
|
const typeOf = db.prepare('SELECT type FROM manga_meta WHERE manga_id = ?')
|
||||||
|
return [...out.values()].filter(w => !isBookId(w.id) && !hasNoVolumes(typeOf.get(w.id)?.type)).sort((a, b) => b.last - a.last)
|
||||||
|
}
|
||||||
|
function route(p, method, res, user) {
|
||||||
|
if (p !== '/api/shelf' || method !== 'GET') return false
|
||||||
|
json(res, 200, { works: works(user.id) })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return { route, works }
|
||||||
|
}
|
||||||
+65
-1
@@ -454,6 +454,28 @@ export function workTitle(title) {
|
|||||||
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
|
/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */
|
||||||
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
|
export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original)
|
||||||
|
|
||||||
|
// Palabras de un título para comparar con nombres de archivo: sin tildes ni vocales largas («Sōsō» y «Sousou» son «soso»).
|
||||||
|
const LOGO_STOP = new Set(['the', 'and', 'wa', 'no', 'ga', 'wo', 'ni', 'de', 'to'])
|
||||||
|
const LOGO_EXTRA = new Set(['logo', 'logotype', 'wordmark', 'manga', 'anime', 'series', 'title', 'english', 'japanese', 'png', 'svg', 'webp', 'file', 'the', 'and', 'tv', 'version', 'new', 'old', 'int', 'international', 'with', 'colour', 'color', 'gradient', 'variant', 'alt', 'alternative', 'text', 'black', 'white', 'transparent', 'vector', 'official'])
|
||||||
|
export const logoWords = s => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/ou/g, 'o').replace(/uu/g, 'u').replace(/oo/g, 'o').split(/[^\p{L}\p{N}]+/u).filter(w => w.length >= 2 && !LOGO_STOP.has(w) && !/^\d+$/.test(w))
|
||||||
|
/** El archivo que mejor encaja con algún nombre de la obra: tiene «logo», casi todas las palabras del nombre y nada más que relleno. */
|
||||||
|
export function pickLogo(files, names) {
|
||||||
|
let best = null, bestScore = 0
|
||||||
|
files.forEach((file, i) => {
|
||||||
|
if (!/\.(png|svg|webp)$/i.test(file)) return
|
||||||
|
const fw = logoWords(file.replace(/^File:/, ''))
|
||||||
|
if (!fw.includes('logo')) return
|
||||||
|
names.forEach((name, k) => {
|
||||||
|
const nw = logoWords(name)
|
||||||
|
if (!nw.length || nw.join('').length < 4) return
|
||||||
|
const hit = nw.filter(w => fw.includes(w)).length / nw.length
|
||||||
|
const stray = fw.filter(w => !nw.includes(w) && !LOGO_EXTRA.has(w))
|
||||||
|
const score = hit + (k ? 0 : 0.05) - i * 0.001 // el nombre principal manda
|
||||||
|
if (hit >= 0.66 && !stray.length && score > bestScore) { best = file; bestScore = score }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return best
|
||||||
|
}
|
||||||
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
|
export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '')
|
||||||
|
|
||||||
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
|
export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) {
|
||||||
@@ -642,6 +664,44 @@ export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches,
|
|||||||
return inflight.get(key)
|
return inflight.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Logo de la obra (Wikimedia Commons), para el título del lomo ----------
|
||||||
|
const COMMONS = 'https://commons.wikimedia.org/w/api.php'
|
||||||
|
let commonsRest = 0 // si Commons pide calma, no se le pregunta nada en un rato (y no se guarda el fallo)
|
||||||
|
async function commonsGet(params) {
|
||||||
|
if (Date.now() < commonsRest) throw new HttpError(503, 'Wikimedia Commons pidió esperar')
|
||||||
|
await turn()
|
||||||
|
net.guard()
|
||||||
|
const r = await fetch(`${COMMONS}?${new URLSearchParams({ ...params, format: 'json', formatversion: '2', maxlag: '5' })}`, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null })
|
||||||
|
const text = r?.ok ? await r.text() : ''
|
||||||
|
if (r?.status === 429 || (r?.ok && !text.startsWith('{')) || /"code":\s*"maxlag"/.test(text)) commonsRest = Date.now() + 10 * 60e3
|
||||||
|
const data = text.startsWith('{') ? JSON.parse(text) : null
|
||||||
|
if (!data || data.error) throw new HttpError(502, 'No se pudo consultar Wikimedia Commons')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
async function commonsLogo(names) {
|
||||||
|
const queries = [...new Set([...names.slice(0, 3), ...names.slice(0, 1).flatMap(n => logoWords(n).sort((a, b) => b.length - a.length).slice(0, 1))])]
|
||||||
|
for (const q of queries) {
|
||||||
|
const res = await commonsGet({ action: 'query', list: 'search', srnamespace: '6', srsearch: `${q} logo`, srlimit: '15' })
|
||||||
|
const best = pickLogo((res.query?.search || []).map(h => h.title), names)
|
||||||
|
if (!best) continue
|
||||||
|
const info = await commonsGet({ action: 'query', titles: best, prop: 'imageinfo', iiprop: 'url|size|mime', iiurlwidth: '960' }) // Wikimedia solo sirve miniaturas de ciertos anchos (960 sí, 800 no)
|
||||||
|
const ii = info.query?.pages?.[0]?.imageinfo?.[0]
|
||||||
|
if (ii && ii.width >= ii.height * 1.2) return ii.thumburl || ii.url || null // un logotipo es apaisado
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Una vez por obra; lo no encontrado se vuelve a buscar a la semana.
|
||||||
|
function logo(mangaId, lang) {
|
||||||
|
return stored(`logo:${mangaId}`, v => (v ? 60 : 7) * DAY, async () => {
|
||||||
|
const { manga } = await fetchMangaFull(mangaId, lang)
|
||||||
|
if (hasNoVolumes(manga.type)) return null
|
||||||
|
const ids = sourceOf(mangaId) === 'md' ? [mangaId] : (await mdMatches(mangaId).catch(() => [])).slice(0, 1)
|
||||||
|
const data = ids.length ? await mdVolumes(ids[0], lang).catch(() => null) : null
|
||||||
|
const names = [...new Set([workTitle(manga.title), manga.title, ...(data?.enTitles || []), ...(data?.roTitles || [])])].filter(Boolean)
|
||||||
|
return commonsLogo(names)
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- API ----------
|
// ---------- API ----------
|
||||||
async function list(mangaId, lang) {
|
async function list(mangaId, lang) {
|
||||||
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
|
const { manga, chapters } = await fetchMangaFull(mangaId, lang)
|
||||||
@@ -680,12 +740,16 @@ export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches,
|
|||||||
if (!groups.has(vol)) groups.set(vol, [])
|
if (!groups.has(vol)) groups.set(vol, [])
|
||||||
groups.get(vol).push(chapters[i].id)
|
groups.get(vol).push(chapters[i].id)
|
||||||
})
|
})
|
||||||
|
// Páginas del tomo, para el grosor del lomo en el estante. Si falta algún capítulo por contar, no se da.
|
||||||
|
const pagesOf = ids => { let n = 0; for (const id of ids) { const p = chapters.find(c => c.id === id)?.pages; if (!(p > 0)) return null; n += p } return n }
|
||||||
return [...groups.keys()].sort(volOrder).map(vol => {
|
return [...groups.keys()].sort(volOrder).map(vol => {
|
||||||
const cover = data?.covers[vol]
|
const cover = data?.covers[vol]
|
||||||
return {
|
return {
|
||||||
vol,
|
vol,
|
||||||
chapters: groups.get(vol),
|
chapters: groups.get(vol),
|
||||||
|
pages: pagesOf(groups.get(vol)),
|
||||||
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
|
thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null,
|
||||||
|
art: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.512.jpg`) : null, // para sacar el arte del lomo
|
||||||
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
|
front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null,
|
||||||
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
|
back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null,
|
||||||
}
|
}
|
||||||
@@ -702,5 +766,5 @@ export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches,
|
|||||||
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
|
res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' })
|
||||||
fs.createReadStream(file).pipe(res)
|
fs.createReadStream(file).pipe(res)
|
||||||
}
|
}
|
||||||
return { list, sendFace }
|
return { list, sendFace, logo: async (mangaId, lang) => { const u = await logo(mangaId, lang); return u ? imgUrl(u) : null } }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user