diff --git a/README.md b/README.md index 0ef5cb9..3ab68cf 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ Lector web de manga autoalojado, con las fuentes de - **Lector**: una página, doble página (tablet horizontal/PC; portada y páginas apaisadas van solas) o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro, - Ctrl+rueda o + / − / 0. + Ctrl+rueda o + / − / 0. Al pasar página la hoja se dobla como papel y, al deslizar, sigue al dedo; + suena un roce de papel elegido al azar entre 6 variantes sintetizadas en el navegador (volumen o + sin sonido en las opciones del lector). - **Reescalado HD**: en la ficha de un manga descargado, "Reescalar a HD" pasa sus capítulos por Real-ESRGAN (`realesr-animevideov3` ×2, en la GPU por Vulkan) y guarda la versión HD en `/.hd/` (carpeta oculta: el CBZ original no se toca y Kavita/Komga no ven duplicados). diff --git a/app/public/app.js b/app/public/app.js index 1cf86cd..4dfa201 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -1186,6 +1186,99 @@ async function viewManga(id, langOverride) { // ---------- Lector ---------- const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' } +// ---------- Sonido al pasar página ---------- +// Seis variantes de roce de papel sintetizadas al momento (sin archivos de audio): ruido filtrado cuyo tono +// sube con la velocidad de la hoja (el aire que mueve), chasquidos sueltos (las fibras del papel) y un golpe +// sordo al caer. Cada vez sale una variante distinta de la anterior, con pequeñas variaciones de duración y +// tono para que no suene nunca igual. +const TURN_SOUNDS = [ + { dur: 0.30, f: [1700, 3600], q: 0.9, peak: 0.55, crackle: 70, thump: 0.25 }, // hoja fina y rápida + { dur: 0.50, f: [800, 2100], q: 0.7, peak: 0.6, crackle: 45, thump: 0.6 }, // hoja gruesa, lenta + { dur: 0.36, f: [2300, 5000], q: 1.1, peak: 0.5, crackle: 240, thump: 0.15 }, // papel crujiente + { dur: 0.42, f: [1200, 2500], q: 0.5, peak: 0.45, crackle: 18, thump: 0 }, // roce suave + { dur: 0.50, f: [1400, 3100], q: 0.8, peak: 0.6, crackle: 110, thump: 0.4, twoStep: true }, // se despega y cae + { dur: 0.26, f: [2800, 5800], q: 1.4, peak: 0.4, crackle: 160, thump: 0.12, snap: true }, // satinado, con chasquido +] +function synthTurn(p, sr, rnd = Math.random) { + const n = Math.round(sr * (p.dur + 0.1)) + const out = new Float32Array(n) + // Filtro paso banda (biquad RBJ, ganancia 1 en el centro). + const band = () => { + let x1 = 0, x2 = 0, y1 = 0, y2 = 0, b0 = 0, a1 = 0, a2 = 0 + return { + set(f, q) { const w = 2 * Math.PI * Math.min(f, sr * 0.45) / sr, al = Math.sin(w) / (2 * q), a0 = 1 + al; b0 = al / a0; a1 = -2 * Math.cos(w) / a0; a2 = (1 - al) / a0 }, + run(x) { const y = b0 * (x - x2) - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y }, + } + } + const air = band(), fiber = band(), body = band() + fiber.set(4200, 0.8) + body.set(170, 0.9) + const a = Math.log(0.5) / Math.log(p.peak) // sin(π·x^a) tiene su máximo en x = peak + const swell = x => x <= 0 || x >= 1 ? 0 : Math.sin(Math.PI * x ** a) ** 2 + const env = x => p.twoStep ? Math.max(0.75 * swell(Math.min(1, x / 0.38)), swell((x - 0.25) / 0.75)) : swell(x) + let flutter = 0, grain = 0, grainK = 1, snapAt = p.snap ? Math.round(sr * p.dur * 0.12) : -1 + const thumpAt = Math.round(sr * p.dur * 0.86), thumpK = Math.exp(-1 / (0.035 * sr)) + let thump = 0 + for (let i = 0; i < n; i++) { + const x = i / (sr * p.dur), e = env(x), w = rnd() * 2 - 1 + if (i % 32 === 0) air.set(p.f[0] + (p.f[1] - p.f[0]) * e, p.q) + flutter += (w - flutter) * 0.004 // temblor lento del volumen + let s = air.run(w) * e * (1 + 1.8 * flutter) + // Chasquidos: más frecuentes cuanto más rápido se mueve la hoja. + if (rnd() < (p.crackle * (0.15 + e)) / sr) { grain = Math.max(grain, (0.3 + 0.7 * rnd()) * (0.3 + e)); grainK = Math.exp(-1 / ((0.0006 + 0.0025 * rnd()) * sr)) } + if (i === snapAt) { grain = 1.6; grainK = Math.exp(-1 / (0.004 * sr)) } + grain *= grainK + s += fiber.run(w) * grain * 1.4 + if (i === thumpAt) thump = p.thump + thump *= thumpK + s += body.run(w) * thump * 3 + out[i] = s + } + let peak = 0 + for (const v of out) peak = Math.max(peak, Math.abs(v)) + const fade = Math.round(sr * 0.01) + for (let i = 0; i < n; i++) out[i] = (out[i] / (peak || 1)) * Math.min(1, (n - i) / fade) + return out +} +const SOUND_LEVELS = { off: ['Sin sonido', 0], low: ['Sonido bajo', 0.15], mid: ['Sonido medio', 0.3], high: ['Sonido alto', 0.55] } +const sfx = { ctx: null, last: -1 } +// El contexto de audio solo arranca dentro de un gesto del usuario (iOS): se prepara al tocar el lector. +function sfxUnlock() { + try { + const AC = window.AudioContext || window.webkitAudioContext + if (!AC) return null + sfx.ctx ??= new AC() + if (sfx.ctx.state !== 'running') sfx.ctx.resume().catch(() => {}) + return sfx.ctx + } catch { return null } +} +// pan: de -1 (izquierda) a 1 (derecha), el lado por el que se mueve la hoja. +function turnSound(level, pan = 0) { + const vol = SOUND_LEVELS[level]?.[1] + if (!vol || document.hidden) return + const ctx = sfxUnlock() + if (!ctx) return + try { + let i + do i = Math.floor(Math.random() * TURN_SOUNDS.length); while (i === sfx.last) + sfx.last = i + const p = TURN_SOUNDS[i], k = 0.88 + Math.random() * 0.24 + const data = synthTurn({ ...p, dur: p.dur * (0.88 + Math.random() * 0.24), f: p.f.map(f => f * k) }, ctx.sampleRate) + const buf = ctx.createBuffer(1, data.length, ctx.sampleRate) + buf.getChannelData(0).set(data) + const src = ctx.createBufferSource() + src.buffer = buf + const gain = ctx.createGain() + gain.gain.value = vol * (0.85 + Math.random() * 0.3) + src.connect(gain) + if (pan && ctx.createStereoPanner) { + const sp = ctx.createStereoPanner() + sp.pan.value = pan + gain.connect(sp).connect(ctx.destination) + } else gain.connect(ctx.destination) + src.start() + } catch {} +} // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). function defaultPrefs(manga) { if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' } @@ -1200,7 +1293,7 @@ async function viewReader(mangaId, chapterId, startPage) { const lang = store.get(`lang:${mangaId}`, me.lang) // Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor). const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches - const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', ...store.get('readerSettings', {}) } + const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) } const [info, chap] = await Promise.all([ api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), @@ -1250,6 +1343,7 @@ async function viewReader(mangaId, chapterId, startPage) { + ${manga.source !== 'md' || chap.local ? '' : ''} @@ -1318,14 +1412,15 @@ 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). function preload() { - for (let k = 1; k <= 4; k++) { - const i = shown.at(-1) + k - if (i >= total) break + const load = i => { const im = new Image() im.onload = () => isWide(im) && wide.add(i) im.src = pages[i] } + 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) } // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…). function showPaged(dir = 0) { @@ -1335,7 +1430,9 @@ async function viewReader(mangaId, chapterId, startPage) { stage.className = 'stage' stage.classList.toggle('two', shown.length === 2) stage.innerHTML = shown.map(i => ``).join('') - if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir) + // 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) else { box.replaceChildren(stage) } stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => { const i = Number(img.dataset.i) @@ -1350,7 +1447,10 @@ async function viewReader(mangaId, chapterId, startPage) { } // --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima; // al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya. - let turning = null // { anims, cleanup } + let turning = null // { anims, cleanup } (y move/release si sigue al dedo) + let wantDrag = false + // Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página). + const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12) function finishTurn() { if (!turning) return const t = turning @@ -1380,6 +1480,9 @@ async function viewReader(mangaId, chapterId, startPage) { function paperCurl(r, frontSrc, backSrc, spineLeft) { const N = Math.max(10, Math.min(22, Math.round(r.w / 24))) const w = r.w / N + // Cada cara se sale OV px por los lados de su tira para tapar la unión con la vecina: sin solape, + // Safari deja una rendija oscura entre tiras. + const OV = 1.5 const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira const el = document.createElement('div') el.className = 'curl' @@ -1391,10 +1494,13 @@ async function viewReader(mangaId, chapterId, startPage) { const s = document.createElement('div') s.className = 'strip' Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' }) - const img = (src, col) => `` - s.innerHTML = `
${img(frontSrc, fc)}
${backSrc ? img(backSrc, bc) : ''}
` + // Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última + // columna). El reverso está girado sobre su centro: allí su izquierda es la derecha de la tira. + const l = fc > 0 ? OV : 0, rr = fc < N - 1 ? OV : 0 + const face = (cls, src, col, off) => `
${src ? `` : ''}
` + s.innerHTML = face('front', frontSrc, fc, l) + face(backSrc ? 'back' : 'back paper', backSrc, bc, rr) parent.append(s) - strips.push({ s, f: s.firstChild.lastChild, b: s.children[1].lastChild }) + strips.push({ s, f: s.children[0], b: s.children[1] }) parent = s } const step = spineLeft ? w : -w @@ -1417,8 +1523,10 @@ async function viewReader(mangaId, chapterId, startPage) { t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)` prev = a const c = Math.cos((a * Math.PI) / 180) - t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse - t.b.style.opacity = ((1 + c) * 0.3).toFixed(3) + // Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari + // cada capa se compone aparte y en las uniones entre tiras asomaban líneas. + t.f.style.filter = `brightness(${(1 - (1 - c) * 0.3).toFixed(3)})` + t.b.style.filter = `brightness(${(1 - (1 + c) * 0.3).toFixed(3)})` lift = Math.max(lift, Math.sin((a * Math.PI) / 180)) }) return lift @@ -1432,9 +1540,22 @@ async function viewReader(mangaId, chapterId, startPage) { while (g1 > g0 && !seen(g1)) g1 -= 0.01 return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)] } - return { el, set, span } + // Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo) + // y el avance g que la pone donde está el dedo. + const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N + function solve(target, reverse) { + let lo = 0, hi = 1 + for (let k = 0; k < 18; k++) { + const m = (lo + hi) / 2 + if ((edge(m, reverse) > target) !== reverse) lo = m + else hi = m + } + return (lo + hi) / 2 + } + return { el, set, span, solve } } - function turnPage(old, stage, dir) { + // drag: la página sigue al dedo; devuelve el control (move/release) en vez de animarse sola. + function turnPage(old, stage, dir, drag = false) { // "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq. const sign = (settings.dir === 'rtl' ? -1 : 1) * dir const fitWidth = box.classList.contains('fit-width') @@ -1449,15 +1570,22 @@ async function viewReader(mangaId, chapterId, startPage) { const double = oldImgs.length === 2 && shown.length === 2 // Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada. if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide' - const anims = [] + // En doble página, el reverso de la hoja es una página nueva: sin cargar, se verían solo las sombras + // de las tiras (líneas oscuras). + if (kind === 'flip' && double && ![...stage.querySelectorAll('img')].every(ready)) kind = 'slide' const extra = [] - const cleanup = () => { old.remove(); extra.forEach(e => e.remove()) } - const ease = 'cubic-bezier(.2,.7,.2,1)' + const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' } + // Al soltar el dedo sin pasar la página: queda la vieja como estaba. + const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '' }) } + const easeOut = t => 1 - (1 - t) ** 3 + // step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance. + let step, dur, range = box.clientWidth, toG = q => q, auto = easeOut, g0 = 0, g1 = 1 if (kind === 'fade') { - anims.push(old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' })) + step = g => { old.style.opacity = String(1 - g) } + dur = 180 } else if (kind === 'slide') { - anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' })) - anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease })) + step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` } + dur = 260 } else { // Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue). // Medidas relativas al contenedor. @@ -1471,7 +1599,9 @@ async function viewReader(mangaId, chapterId, startPage) { // la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia // encima de la nueva) hasta que la hoja cae. const oldX = byX(oldImgs) - const newX = byX(stage.querySelectorAll('img')) + // Las nuevas puede que aún no estén cargadas (sin tamaño ni posición): su lado sale del orden de lectura. + const newX = [...stage.querySelectorAll('img')] + if (settings.dir === 'rtl') newX.reverse() const front = sign > 0 ? oldX.at(-1) : oldX[0] const stay = sign > 0 ? oldX[0] : oldX.at(-1) rect = rel(front) @@ -1504,14 +1634,41 @@ async function viewReader(mangaId, chapterId, startPage) { place(curl.el, rect) box.append(drop, curl.el) extra.push(drop, curl.el) - const [g0, g1] = double ? [0, 1] : curl.span(reverse) - anims.push(frameAnim(double ? 700 : 560, t => { - const lift = curl.set(g0 + (g1 - g0) * t, reverse) - drop.style.opacity = (lift * 0.8).toFixed(3) - })) + ;[g0, g1] = double ? [0, 1] : curl.span(reverse) + step = g => { drop.style.opacity = (curl.set(g, reverse) * 0.8).toFixed(3) } + dur = double ? 700 : 560 + auto = t => g0 + (g1 - g0) * t + // El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble); + // hacia atrás en una página, del lomo a su sitio. + range = double ? 2 * rect.w : rect.w + toG = q => curl.solve(reverse ? q : 1 - q * (double ? 2 : 1), reverse) } + if (drag) { + let g = toG(0) + step(g) + const ctl = { + anims: [], cleanup, range, + move(q) { g = toG(Math.min(1, Math.max(0, q))); step(g) }, + // Termina de pasar la página (commit) o la devuelve a su sitio; done(commit) al acabar. + release(commit, done) { + const from = g, to = commit ? g1 : g0 + const a = frameAnim(Math.max(120, (dur * Math.abs(to - from)) / (g1 - g0 || 1)), t => { g = from + (to - from) * easeOut(t); step(g) }) + ctl.anims = [a] + a.finished.then(() => { + if (turning !== ctl) return + turning = null + if (commit) cleanup() + else revert() + done(commit) + }, () => {}) + }, + } + turning = ctl + return + } + const anims = [frameAnim(dur, t => step(auto(t)))] turning = { anims, cleanup } - Promise.all(anims.map(a => a.finished)).then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {}) + anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {}) } function showEnd() { finishTurn() @@ -1644,7 +1801,9 @@ async function viewReader(mangaId, chapterId, startPage) { } box.addEventListener('pointerdown', e => { if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return + if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página if (e.pointerType === 'mouse' && e.button !== 0) return + if (settings.sfx !== 'off') sfxUnlock() pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) if (pts.size === 1) { tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false } @@ -1659,7 +1818,35 @@ async function viewReader(mangaId, chapterId, startPage) { box.addEventListener('pointermove', e => { if (!pts.has(e.pointerId)) return pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) + if (gesture?.type === 'turn') { + if (turning !== gesture.ctl) return + const d = (e.clientX - gesture.x0) * gesture.k + gesture.v = (d - gesture.d) / Math.max(1, e.timeStamp - gesture.t) + gesture.d = d + gesture.t = e.timeStamp + gesture.ctl.move(d / gesture.ctl.range) + return + } if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true + // Arrastre horizontal con un dedo: la página empieza a pasar y sigue al dedo. + if (tap && !gesture && pts.size === 1 && zoom.s === 1 && e.pointerType !== 'mouse' && settings.anim !== 'none') { + const dx = e.clientX - tap.x, dy = e.clientY - tap.y + if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) { + const fwd = (dx < 0) === (settings.dir === 'ltr') + if (fwd ? shown.at(-1) + 1 < total : page > 0) { + const prev = [...shown] + finishTurn() + wantDrag = true + try { fwd ? forward() : backward() } finally { wantDrag = false } + if (turning?.move) { + gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp } + turning.move(gesture.d / turning.range) + box.setPointerCapture?.(e.pointerId) + } + tap = null + } + } + } if (gesture?.type === 'pinch' && pts.size >= 2) { const [a, b] = [...pts.values()] const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 @@ -1678,6 +1865,24 @@ async function viewReader(mangaId, chapterId, startPage) { }) const endPointer = e => { if (!pts.delete(e.pointerId)) return + if (gesture?.type === 'turn') { + // Se pasa si se ha llevado más de un 30 % o se ha soltado con impulso hacia delante. + const g = gesture + gesture = null + tap = null + if (turning !== g.ctl) return + const q = g.d / g.ctl.range + const commit = e.type === 'pointerup' && (g.v > 0.35 || (q > 0.3 && g.v > -0.15)) + if (commit) turnSound(settings.sfx, turnPan(g.dir)) + g.ctl.release(commit, ok => { + if (ok) return + shown = g.prev + page = g.prev[0] + setPageLabel() + scheduleSave() + }) + return + } if (pts.size === 1 && zoom.s > 1) { const [p] = [...pts.values()] gesture = { type: 'pan', x: p.x, y: p.y } @@ -1707,7 +1912,7 @@ async function viewReader(mangaId, chapterId, startPage) { $('#zoomreset').addEventListener('click', resetZoom) $('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) }) - const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim }) + const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx }) const savePrefs = () => { api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) .then(() => toast('Guardado para este manga')).catch(err => toast(err.message)) @@ -1723,6 +1928,8 @@ async function viewReader(mangaId, chapterId, startPage) { $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() }) $('#anim').value = settings.anim $('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() }) + $('#sfx').value = SOUND_LEVELS[settings.sfx] ? settings.sfx : 'mid' + $('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) }) $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() }) $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() }) $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() }) diff --git a/app/public/style.css b/app/public/style.css index 55d9d6d..2f40088 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -359,11 +359,10 @@ body.reading #netbanner { display: none; } .paged.fit-width .stage.leaving { bottom: auto; } .paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; } .paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; } -.paged .curl .sf { position: absolute; top: 0; bottom: 0; left: -0.5px; right: -0.5px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; } +.paged .curl .sf { position: absolute; top: 0; bottom: 0; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; } .paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; } .paged .curl .back { transform: rotateY(180deg); } .paged .curl .back.paper { background: #e9e5dc; } -.paged .curl .sf i { position: absolute; inset: 0; background: #000; opacity: 0; } .paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; } .paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); } .paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }