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 ----------
|
||||
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() {
|
||||
if (location.hash !== curHash) { prevHash = curHash; curHash = location.hash }
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
@@ -371,6 +372,7 @@ async function router() {
|
||||
const [pathPart, query] = location.hash.slice(1).split('?')
|
||||
const parts = (pathPart || '/').split('/').filter(Boolean)
|
||||
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] ?? ''}`))
|
||||
|
||||
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] === 'history') return (location.hash = '#/library') // antiguo nombre
|
||||
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] === 'queues') return await viewQueues()
|
||||
if (parts[0] === 'stats') return await viewStats()
|
||||
@@ -2102,7 +2105,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
app.innerHTML = `
|
||||
<div class="reader">
|
||||
<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>
|
||||
<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>
|
||||
@@ -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.
|
||||
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 b3dPaper = () => {
|
||||
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>
|
||||
${sub ? `<div class="sub">${sub}</div>` : ''}
|
||||
</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) {
|
||||
const tab = params.get('tab') || 'reading'
|
||||
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
|
||||
]
|
||||
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('')}
|
||||
${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>
|
||||
@@ -4046,7 +4164,7 @@ async function viewQueues() {
|
||||
</section>
|
||||
<section class="qsec">
|
||||
<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({
|
||||
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,
|
||||
@@ -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>
|
||||
<form id="notify" style="display:grid;gap:8px">
|
||||
<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="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>
|
||||
|
||||
@@ -115,9 +115,9 @@ export function fitCamera(points, aspect, marginX, marginY) {
|
||||
// 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 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 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.
|
||||
export function coverPalette(data) {
|
||||
const boxes = new Map()
|
||||
@@ -143,7 +143,7 @@ export function coverPalette(data) {
|
||||
}
|
||||
|
||||
// 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')
|
||||
let seed = 7
|
||||
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
|
||||
@@ -243,7 +243,7 @@ void main() {
|
||||
}`
|
||||
|
||||
// Junta rejillas en una malla: pos(3) normal(3) uv(2).
|
||||
class Shape {
|
||||
export class Shape {
|
||||
constructor() { this.v = []; this.i = [] }
|
||||
grid(cols, rows, at) {
|
||||
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.
|
||||
// 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
|
||||
function createTilt(onChange) {
|
||||
export const MAX_DEG = 15
|
||||
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 }
|
||||
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))
|
||||
|
||||
@@ -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 name="sw-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="theme-color" content="#14121a">
|
||||
<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; } }
|
||||
.chapters .meta .cdate.approx { opacity: .7; }
|
||||
/* 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[hidden] { display: none; }
|
||||
#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 > #b3d { display: none; }
|
||||
.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.
|
||||
const SHELL = 'shell-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
|
||||
// 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 => {
|
||||
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()))
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user