diff --git a/app/gyrolab.js b/app/gyrolab.js new file mode 100644 index 0000000..3b9b3a5 --- /dev/null +++ b/app/gyrolab.js @@ -0,0 +1,37 @@ +// Laboratorio del giroscopio (solo para desarrollo; se borrará junto con public/gyrolab.js y su botón en Ajustes). +// Guarda las grabaciones de los sensores en DATA_DIR/gyrolab/, una por paso del guion. +import fs from 'node:fs' +import path from 'node:path' + +const MAX_BYTES = 4 * 1024 * 1024 +const SESSION = /^[a-z0-9]{4,24}$/, STEP = /^[a-z0-9-]{2,24}$/ + +export function setupGyroLab({ DATA_DIR, HttpError, json }) { + const dir = path.join(DATA_DIR, 'gyrolab') + async function readRaw(req) { + let size = 0 + const chunks = [] + for await (const c of req) { + size += c.length + if (size > MAX_BYTES) throw new HttpError(413, 'Grabación demasiado grande') + chunks.push(c) + } + try { return JSON.parse(Buffer.concat(chunks).toString()) } catch { throw new HttpError(400, 'JSON inválido') } + } + async function route(p, method, req, res, user) { + if (p !== '/api/gyrolab') return false + if (method === 'GET') { + const mine = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.startsWith(`u${user.id}-`)) : [] + return json(res, 200, { files: mine.sort() }) + } + if (method !== 'POST') throw new HttpError(405, 'Método no permitido') + const body = await readRaw(req) + if (!SESSION.test(body?.session || '') || !STEP.test(body?.step || '')) throw new HttpError(400, 'Sesión o paso no válidos') + if (!Array.isArray(body.ori) || !Array.isArray(body.mot)) throw new HttpError(400, 'Faltan lecturas') + fs.mkdirSync(dir, { recursive: true }) + const name = `u${user.id}-${body.session}-${body.step}.json` + fs.writeFileSync(path.join(dir, name), JSON.stringify({ ...body, user: user.username, savedAt: Date.now() })) + return json(res, 200, { ok: true, file: name }) + } + return { route } +} diff --git a/app/public/app.js b/app/public/app.js index 93cf720..533adc1 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -41,6 +41,7 @@ const ICONS = { queue: '', library: '', bookmark: '', + shelf: '', phone: '', plus: '', pencil: '', @@ -388,12 +389,13 @@ 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] === 'shelf') return await viewShelf(params) if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre if (parts[0] === 'queues') return await viewQueues() if (parts[0] === 'stats') return await viewStats() if (parts[0] === 'clips') return await viewClips(params) if (parts[0] === 'settings') return viewSettings() + if (parts[0] === 'gyrolab') return await viewGyroLab() // solo desarrollo return await viewHome(params) } catch (e) { if (e.message !== 'No autenticado') app.innerHTML = `

${esc(e.message)}

` @@ -722,6 +724,7 @@ async function viewManga(id, langOverride) {
${mainBtn} ${chapters.length ? `` : ''} + ${SHELF_OK && !isBook && !/^(manhwa|manhua|webtoon)$/i.test(manga.type || '') ? `${icon('shelf')} Estante` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''} @@ -3073,6 +3076,7 @@ async function viewReader(mangaId, chapterId, startPage) { 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 + if (fromShelf) document.body.dataset.handoff = b3dWanted() ? '3d' : 'flat' // la foto del estante espera al libro 3D, o no (handoffCover) const b3dPaper = () => { const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf' return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255) @@ -3823,40 +3827,77 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => ` { img.classList.add('out'); setTimeout(() => img.remove(), 450) } + let mounted = 0 // cuándo se supo que el lector se monta sin libro 3D + delete document.body.dataset.handoff // lo dice el lector al montarse: '3d' o 'flat' + const fade = why => { img.dataset.why = why; delete document.body.dataset.handoff; document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) } const wait = () => { - if (!img.isConnected) return + if (!img.isConnected) return document.body.classList.remove('handing') 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() + if (ready) return setTimeout(() => fade('3d'), 350) // las texturas de la tapa tardan un instante + // Montado sin libro 3D (modo vertical, ajuste a lo ancho): ya no habrá lienzo que esperar. Con él, se espera a que dibuje. + if (!mounted && document.body.dataset.handoff === 'flat') mounted = performance.now() + // El lector no llegó a montarse (un error en su lugar). Mientras siga el estante, la ruta aún no ha cambiado de vista. + const failed = !document.querySelector('.reader, #shelf3d') && performance.now() - t0 > 400 + if (failed) return fade('error') + if (mounted && performance.now() - mounted > 600) return fade('flat') + if (performance.now() - t0 > 90000) return fade('tarde') // por si el lector se queda colgado: mientras carga, la foto sigue + if (!location.hash.startsWith('#/read/')) return fade('ruta') setTimeout(wait, 60) } - wait() + // Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector. + return (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => new Promise(r => requestAnimationFrame(() => { setTimeout(wait, 120); r() }))) // para entonces ya se está en el lector } -async function viewShelf() { +// Lo último que se supo del estante, en este dispositivo: con ello se monta al momento (sin esperar a cada obra) +// y sin conexión. Tomos y logos se refrescan luego, y solo se rehace lo que cambie. +const shelfSnapKey = () => `shelfSnap:${me?.username || ''}` +const LOGO_DAYS = { found: 30, none: 7 } +const shelfSort = (works, order) => order === 'az' + ? [...works].sort((a, b) => String(a.title).replace(/^[^\p{L}\p{N}]+/u, '').localeCompare(String(b.title).replace(/^[^\p{L}\p{N}]+/u, ''), 'es', { sensitivity: 'base', numeric: true })) + : [...works].sort((a, b) => (b.last || 0) - (a.last || 0)) +async function viewShelf(params) { if (!SHELF_OK) { app.innerHTML = `

Estante 3D

Este navegador no tiene WebGL2.

${icon('library')} Volver a la librería

` return } document.body.classList.add('reading') // sin la barra de arriba - app.innerHTML = `
${icon('back')} Librería

Colocando los tomos…

+ let order = store.get('shelfOrder', 'recent') + const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`) + app.innerHTML = `
+

` - const note = $('#shelfnote'), card = $('#shelfpeek') - const focusOn = shelfTrip?.mangaId || null // al volver del lector, con ese tomo a la vista + const note = $('#shelfnote'), card = $('#shelfpeek'), orderBtn = $('#shelforder') + orderBtn.innerHTML = orderLabel() + const say = (text, ms) => { clearTimeout(note.t); note.textContent = text; note.hidden = !text; if (ms) note.t = setTimeout(() => { note.hidden = true }, ms) } + const focusOn = shelfTrip?.mangaId || params?.get('focus') || null // al volver del lector, o desde la ficha shelfTrip = null - let shelf = null, gone = false + let shelf = null, gone = false, offline = !serverReachable const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() } document.addEventListener('keydown', onKey) - cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); shelf?.destroy(); shelf = null } + cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); clearTimeout(snapTimer); saveSnap(); shelf?.destroy(); shelf = null } + // Foto del estante en el dispositivo. + const snap = store.get(shelfSnapKey(), null) + const logos = snap?.logos || {} // id -> { url, at } + let works = [], snapTimer = 0 + const saveSnap = () => { if (works.length && !offline) store.set(shelfSnapKey(), { at: Date.now(), works, logos }) } + const saveSoon = () => { clearTimeout(snapTimer); snapTimer = setTimeout(saveSnap, 1500) } + // Sin conexión, cada tomo se lee por los capítulos guardados en el dispositivo. + const onDevice = id => !offline || offHas(id) // Vistazo: lo que se sabe del tomo sacado, junto a él. const readOf = info => { const { work, vol, state } = info + if (offline) { + if (state === 'reading' && work.chapterId && offHas(work.chapterId) && (!vol.chapters?.length || vol.chapters.includes(work.chapterId))) return readUrl(work.id, work.chapterId, work.page || 0) + const here = (vol.chapters || []).find(offHas) + return here ? readUrl(work.id, here) : null + } 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}` @@ -3866,72 +3907,126 @@ async function viewShelf() { const toTable = info => { if (leaving) return const href = readOf(info) - const go = snap => { + if (!href) return say('Este tomo no está guardado en el dispositivo.', 2500) + const go = async snap => { if (gone) return if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id } - if (snap) handoffCover(snap) + if (snap) await handoffCover(snap) // la foto ya está pintada antes de quitar el estante: no se ve nada entre medias location.hash = href } leaving = true if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null) } + // El tomo sacado lleva su nota, como la de la balda, con lo que se sabe de él. 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' + const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : ''].filter(Boolean).join(' · ') + // Por dónde va, escrito: el capítulo de la obra (si se sabe su número) y la página. + const page = work.page > 0 ? `, página ${work.page + 1}` : '' + const where = state === 'read' ? 'Ya leído' + : state === 'reading' || state === 'unknown' ? (work.chapterNum != null && work.chapterNum !== '' ? `Vas por el capítulo ${esc(work.chapterNum)}${page}` : at >= 0 ? `Vas por el capítulo ${at + 1} de este tomo${page}` : 'Lo estás leyendo') + : 'Sin empezar' + const href = readOf(info), st = shelfMod.noteStyle(work.id) + card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg` + // Un pósit de verdad: sin botones. Se lee tocando el tomo (o esta línea, que lleva al mismo sitio). card.innerHTML = ` -

${esc(work.title)}

${facts ? `

${facts}

` : ''} -

${where}

- ` +

${esc(shelfMod.shortTitle(work.title))}

${facts ? `

${facts}

` : ''} +

${where}

+ ${href ? `Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →` : '

No está guardado en el dispositivo

'}` card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek()) - card.querySelector('#peekread').addEventListener('click', e => { e.preventDefault(); toTable(info) }) + 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, + let shelfMod = null + // Lo guardado en el dispositivo, para cuando no hay servidor. + const offlineWorks = () => { + const known = new Map((snap?.works || []).map(w => [w.id, w])) + return offByManga().filter(m => !isBookId(m.mangaId)).map(m => known.get(m.mangaId) || { id: m.mangaId, title: m.title, cover: m.cover, last: m.at, chapterId: null, page: 0, lang: m.chapters[0]?.lang || null }) + } + const start = (list, lib) => { + works = list + shelf = shelfMod.createShelf3D($('#shelf3d'), lib, { + works: shelfSort(works, order), 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), () => []), + pagesOf: offline ? null : 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 + for (const w of works) if (logos[w.id]?.url) shelf.setLogo(w.id, logos[w.id].url) + } + orderBtn.addEventListener('click', () => { + order = order === 'az' ? 'recent' : 'az' + store.set('shelfOrder', order) + orderBtn.innerHTML = orderLabel() + shelf?.unpeek() + shelf?.setWorks(shelfSort(works, order)) + shelf?.toTop() + }) + try { + const [lib, mod] = await Promise.all([import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)]) + shelfMod = mod + if (gone) return + mod.loadNoteFonts() + // Con foto guardada, el mueble sale ya; la lista del servidor llega después. + if (snap?.works?.length && !offline) start(snap.works.filter(w => wantsId(w.id)), lib) + else if (!snap?.works?.length && !offline) say('Colocando los tomos…') + let fresh = null + if (!offline) fresh = await api('/api/shelf').catch(e => { if (e.message === NO_SERVER || !serverReachable) offline = true; else throw e; return null }) + if (gone) return + if (offline) { + const list = offlineWorks() + if (shelf) { works = list; shelf.setWorks(shelfSort(works, order)) } else if (list.length) start(list, lib) + return say(list.length ? 'Sin conexión: solo lo guardado en este dispositivo.' : 'Sin conexión, y no hay tomos guardados en este dispositivo.', list.length ? 4000 : 0) + } + // La lista del servidor manda; de la foto se conservan los tomos ya conocidos. + const had = new Map(works.map(w => [w.id, w])) + const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w)) + if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') } + const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.chapterNum === w.chapterNum && o.page === w.page && o.last === w.last }) + if (!shelf) start(next, lib) + else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) } + else works = next.map(w => had.get(w.id)) + saveSoon() + // Los tomos llegan aparte, de pocos en pocos: primero las obras que aún no tienen, luego se repasan las demás. + const queue = [...works.filter(w => !w.vols), ...works.filter(w => w.vols)] + let pending = queue.filter(w => !w.vols).length + if (!pending) say('') const worker = async () => { - while (next < works.length && !gone) { - const w = works[next++] + while (queue.length && !gone) { + const w = queue.shift(), first = !w.vols 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 + const vols = r?.volumes?.length ? r.volumes : null + if (vols && JSON.stringify(vols) !== JSON.stringify(w.vols || null)) { w.vols = vols; shelf.setVolumes(w.id, vols); saveSoon() } + if (first && --pending <= 0) say('') } } 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 day = 864e5, now = Date.now() + const logoQueue = works.filter(w => { const l = logos[w.id]; return !l || now - (l.at || 0) > (l.url ? LOGO_DAYS.found : LOGO_DAYS.none) * day }) 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) + while (logoQueue.length && !gone) { + const w = logoQueue.shift() + const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => undefined) if (gone) return - if (r?.logo) shelf.setLogo(w.id, r.logo) + if (r === undefined) continue // error: se vuelve a preguntar en la próxima visita + logos[w.id] = { url: r.logo || null, at: Date.now() } + if (r.logo) shelf.setLogo(w.id, r.logo) + saveSoon() } } for (let k = 0; k < 2; k++) logoWorker() } catch (e) { - if (!gone) note.textContent = `No se pudo abrir el estante: ${e.message}` + if (!gone) say(`No se pudo abrir el estante: ${e.message}`) } } @@ -3955,7 +4050,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 = `

Librería

${SHELF_OK ? `${icon('library')} Estante 3D` : ''}
+ app.innerHTML = `

Librería

${SHELF_OK ? `${icon('shelf')} Estante 3D` : ''}
${tabs.map(([k, label, ic, n]) => ``).join('')} ${online ? `` : ''}
${curList ? `
Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas @@ -4346,6 +4441,13 @@ async function tableJpeg(file) { return blob } finally { URL.revokeObjectURL(url) } } +// Laboratorio del giroscopio: solo para desarrollo, se borrará (con public/gyrolab.js, gyrolab.js del servidor y su botón en Ajustes). +async function viewGyroLab() { + app.innerHTML = '

Cargando el laboratorio…

' + const m = await import(`/gyrolab.js?v=${Date.now()}`) + if (!location.hash.startsWith('#/gyrolab')) return + cleanup = m.mountGyroLab(app, { esc, toast }) +} function viewSettings() { app.innerHTML = `

Ajustes

@@ -4381,6 +4483,9 @@ function viewSettings() {
` : `

Instalar la app y leer sin conexión necesita que la página se abra por HTTPS (el navegador no lo permite en http://${esc(location.host)}). Por ejemplo con tailscale serve o un proxy inverso con certificado.

`} +
${icon('phone')} Desarrollo: laboratorio del giroscopio +

Graba los sensores de movimiento de este dispositivo para calibrar el giroscopio del estante y del libro 3D. Es temporal: se quitará al terminar.

+
${icon('key')} Seguridad: IPs bloqueadas
Cargando…
${icon('bell')} Avisos de capítulos nuevos

El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos). diff --git a/app/public/book3d.js b/app/public/book3d.js index 3c314bb..15c6151 100644 --- a/app/public/book3d.js +++ b/app/public/book3d.js @@ -303,33 +303,48 @@ function rimShape(pts, y0, y1, thick) { return s } -// 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. +// El sensor. Fuente principal: el giroscopio (devicemotion.rotationRate, en °/s y en los ejes del dispositivo), que no falla con +// el dispositivo vertical. La orientación del sistema (deviceorientation) solo se usa si no hay giroscopio: sus ángulos β/γ saltan +// cerca de β = 90°, aunque el dispositivo esté quieto. La inclinación acumulada vuelve sola al centro con el tiempo y se acerca al +// tope con suavidad (tanh). Al caminar, la agitación que mide el acelerómetro baja la sensibilidad. +// Calibrado con grabaciones reales (laboratorio del giroscopio, ver HANDOFF-LIBRO3D.md). +// MAX_DEG: lo que hay que girar el dispositivo para llegar al 76 % del tope. ORBIT_*: cuánto rodea la cámara al libro en el tope, en grados. export const MAX_DEG = 15 -const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06 +const FADE_S = 2.5, QUIET = 0.6, DEAD = 1, ORBIT_TILT = 10, ORBIT_YAW = 15, ORBIT_BACK = 0.12 +const SMOOTH_S = 0.058, WALK_FROM = 0.25, WALK_K = 2.5, WALK_S = 0.4 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)) + const target = { x: 0, y: 0 }, raw = { x: 0, y: 0 } + let listening = false, gotGyro = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null + // iOS da la gravedad con el signo al revés que la especificación; se confirma con β en cuanto se puede. + let gSign = window.DeviceMotionEvent?.requestPermission ? -1 : 1, grav = null, land = null, shake = 0 + const LIM = 2 * MAX_DEG + const lim = v => Math.max(-LIM, Math.min(LIM, v)) + const soft = () => { for (const k of ['x', 'y']) target[k] = MAX_DEG * Math.tanh(raw[k] / MAX_DEG) } + // Giro de la pantalla. Safari del iPad informa mal el ángulo (90 en vertical, 180 en horizontal con el borde derecho abajo): + // la disposición dice si es vertical u horizontal y, en horizontal, la gravedad dice hacia qué lado. function screenAngle() { let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360 const landscape = innerWidth > innerHeight - if (landscape && a % 180 === 0) a = 90 - if (!landscape && a % 180) a = 0 + if (landscape) { + if (grav && Math.abs(grav[0]) > 3) land = grav[0] > 0 ? 90 : 270 + a = land ?? (a % 180 ? a : 90) + } else if (a % 180) a = 0 return (a * Math.PI) / 180 } function add(dx, dy) { const a = screenAngle(), c = Math.cos(a), s = Math.sin(a) - target.x = clamp(target.x + dx * c - dy * s) - target.y = clamp(target.y + dx * s + dy * c) + raw.x = lim(raw.x + dx * c - dy * s) + raw.y = lim(raw.y + dx * s + dy * c) + soft() lastFade = lastFade || performance.now() onChange() } const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt } - const quiet = v => (Math.abs(v) < QUIET ? 0 : v) function onOrientation(e) { if (e.beta == null || e.gamma == null) return - gotOrientation = true + const sb = Math.sin((e.beta * Math.PI) / 180) + if (grav && Math.abs(sb) > 0.7 && Math.abs(grav[1]) > 6) gSign *= Math.sign(grav[1] * sb) // y de la reacción a la gravedad = 9,8·sin β + if (gotGyro) return const dt = stepOf(e.timeStamp) if (lastOri) { const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g @@ -339,10 +354,16 @@ export function createTilt(onChange) { lastOri = { b: e.beta, g: e.gamma } } function onMotion(e) { - const r = e.rotationRate - if (gotOrientation || r?.alpha == null || r?.beta == null) return + const r = e.rotationRate, g = e.accelerationIncludingGravity, l = e.acceleration + if (g?.x != null && g?.y != null) grav = [g.x * gSign, g.y * gSign] + if (r?.alpha == null || r?.beta == null) return + gotGyro = true const dt = stepOf(e.timeStamp) - add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y + if (l?.x != null) shake += (Math.hypot(l.x, l.y || 0, l.z || 0) - shake) * Math.min(1, dt / WALK_S) + const speed = Math.hypot(r.alpha, r.beta) // especificación: alpha es el eje x y beta el eje y; gamma (girar como un volante) no cuenta + if (speed <= DEAD) return + const k = ((speed - DEAD) / speed) * dt / (1 + Math.max(0, shake - WALK_FROM) * WALK_K) + add(r.alpha * k, r.beta * k) } function listen(on) { if (on === listening) return @@ -350,7 +371,7 @@ export function createTilt(onChange) { const method = on ? 'addEventListener' : 'removeEventListener' window[method]('devicemotion', onMotion) window[method]('deviceorientation', onOrientation) - target.x = target.y = 0 + target.x = target.y = raw.x = raw.y = shake = 0 lastT = performance.now() lastOri = null onChange() @@ -377,11 +398,26 @@ export function createTilt(onChange) { fade(now) { const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S) lastFade = now - for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep + for (const k of ['x', 'y']) raw[k] = Math.abs(raw[k] * keep) < 0.3 ? 0 : raw[k] * keep + soft() }, stop() { forget(); listen(false) }, } } +// La cámara sigue a la inclinación con un suavizado por tiempo, igual a 60 que a 120 Hz. cur: { x, y } en fracción del tope. +export function followTilt(tilt, cur, now, on) { + tilt.fade(now) + const dt = Math.min(1 / 30, Math.max(0, (now - (cur.t ?? now)) / 1000)), a = 1 - Math.exp(-dt / SMOOTH_S) + cur.t = now + const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 } + let moving = !!(tilt.target.x || tilt.target.y) && on + for (const k of ['x', 'y']) { + const d = want[k] - cur[k] + if (Math.abs(d) < 0.002) cur[k] = want[k] + else { cur[k] += d * a; moving = true } + } + return moving +} export function createBook3D(container, opts) { const { total, src, whenReady, spreadOf, isWide, still = false } = opts @@ -778,18 +814,7 @@ export function createBook3D(container, opts) { return pts.at(-1) } - function stepTilt(now) { - tilt.fade(now) - const on = !still && zoom.s === 1 - const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 } - let moving = !!(tilt.target.x || tilt.target.y) && on - for (const k of ['x', 'y']) { - const d = want[k] - tiltNow[k] - if (Math.abs(d) < 0.002) tiltNow[k] = want[k] - else { tiltNow[k] += d * 0.25; moving = true } - } - return moving - } + const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom.s === 1) function stepAnim(now) { if (!anim) return false const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación @@ -1048,7 +1073,7 @@ export function createBook3D(container, opts) { }, draw, // Para las pruebas. - debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }), + debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { x: tiltNow.x, y: tiltNow.y }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }), destroy() { dead = true cancelAnimationFrame(raf) diff --git a/app/public/gyrolab.js b/app/public/gyrolab.js new file mode 100644 index 0000000..5d79ac6 --- /dev/null +++ b/app/public/gyrolab.js @@ -0,0 +1,312 @@ +// Laboratorio del giroscopio (solo para desarrollo; se borrará junto con /gyrolab.js del servidor y su botón en Ajustes). +// Graba a la vez las tres fuentes: la orientación que calcula el sistema (deviceorientation), el giroscopio +// (rotationRate) y el acelerómetro con y sin gravedad (devicemotion), siguiendo un guion de situaciones. +// Cada paso se sube a /api/gyrolab en cuanto termina. + +const STEPS = [ + { id: 'mesa', name: 'Apoyado en la mesa', say: 'Déjalo sobre la mesa con la pantalla hacia arriba y no lo toques.', secs: 10, lead: 5 }, + { id: 'quieto', name: 'En la mano, quieto', say: 'Sostenlo como cuando lees, sin moverlo a propósito.', secs: 10, lead: 3 }, + { id: 'lento', name: 'Inclinar despacio', say: 'Inclínalo despacio hacia ti y sostén 2 s. Luego hacia un lado y sostén. Luego vuelve.', secs: 14, lead: 3 }, + { id: 'rapido', name: 'Inclinar rápido', say: 'Inclínalo rápido a un lado y vuelve, varias veces, a los dos lados.', secs: 10, lead: 3 }, + { id: 'vertical', name: 'Casi vertical', say: 'Sostenlo casi vertical frente a ti, como leyendo de pie, e inclínalo un poco a los lados.', secs: 12, lead: 3 }, + { id: 'acostado', name: 'Acostado', say: 'Recuéstate o acuéstate como para leer y, a la mitad, cambia de postura una vez.', secs: 15, lead: 5 }, + { id: 'girar', name: 'Girar la pantalla', say: 'Gíralo a horizontal, espera 2 s y vuelve a vertical. Quita antes el bloqueo de rotación.', secs: 12, lead: 3 }, + { id: 'caminar', name: 'Caminando', say: 'Camina unos pasos sosteniéndolo como para leer.', secs: 12, lead: 3 }, +] +const KEEP_S = 5 // lo que enseña la gráfica en vivo +const COLORS = ['#ff7a8a', '#6fb3ff', '#8be28b'] +const CSS = ` +.glab { max-width: 760px; margin: 0 auto; display: grid; gap: 14px; } +.glab h1 { margin: 0; } +.glab .gtop { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.glab .gtop input { flex: 1; min-width: 140px; } +.glab .glive { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 8px; font: 12px/1.45 ui-monospace, Menlo, monospace; } +.glab .glive div { background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); padding: 8px 10px; } +.glab .glive b { font-family: system-ui, sans-serif; font-size: 13px; display: block; } +.glab canvas { width: 100%; height: 240px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); } +.glab ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.glab li { display: flex; gap: 12px; align-items: center; background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); padding: 10px 12px; } +.glab li .gtxt { flex: 1; min-width: 0; } +.glab li .gtxt small { display: block; color: var(--muted); } +.glab li.done { border-color: color-mix(in srgb, var(--accent) 55%, transparent); } +.glab li .gok { color: var(--accent); font-weight: 600; white-space: nowrap; } +.gover { position: fixed; inset: 0; z-index: 1000; background: rgba(10, 9, 14, .94); display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 18px; padding: 24px; text-align: center; } +.gover .gbig { font-size: clamp(48px, 18vw, 120px); font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; } +.gover .gsay { font-size: 1.15rem; max-width: 34em; } +.gover .gbar { width: min(420px, 80vw); height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } +.gover .gbar i { display: block; height: 100%; width: 0; background: var(--accent); } +` + +const r3 = v => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000) +const fmt = v => (v == null ? ' — ' : (v >= 0 ? ' ' : '') + v.toFixed(2)) +const guessDevice = () => { + const ua = navigator.userAgent + if (/iPad|Macintosh/.test(ua) && navigator.maxTouchPoints > 1) return 'iPad' + if (/iPhone/.test(ua)) return 'iPhone' + const m = /Android [^;]*; ([^;)]+)/.exec(ua) + return m ? m[1].trim() : 'otro' +} +const load = () => { try { return JSON.parse(localStorage.getItem('gyrolab')) || null } catch { return null } } +const save = s => { try { localStorage.setItem('gyrolab', JSON.stringify(s)) } catch {} } +const newSession = () => ({ session: Date.now().toString(36), done: [] }) + +export function mountGyroLab(app, { esc, toast }) { + const style = document.createElement('style') + style.textContent = CSS + document.head.append(style) + let state = load() || newSession() + let device = localStorage.getItem('gyrolabDevice') || guessDevice() + let listening = false, audio = null, wake = null, raf = 0, rec = null, timers = [], pending = null, dead = false + let interval = null, permission = 'sin pedir', lastOri = null, lastMot = null + const hist = { ori: [], mot: [], frames: [] } // lecturas recientes, para la gráfica y los Hz + + app.innerHTML = `

+

Laboratorio del giroscopio

+

Solo para desarrollo. Graba la orientación del sistema, el giroscopio y el acelerómetro mientras haces cada paso. + Cada grabación se sube sola al servidor. Haz los pasos en el orden que quieras y repite los que salgan mal.

+
+ + +
+

Toca «Activar sensores» para empezar.

+
+ +
    +
    +
    ` + const $ = s => app.querySelector(s) + const msg = t => { $('#gmsg').textContent = t } + + function renderSteps() { + $('#gsess').textContent = `Sesión ${state.session} · ${state.done.length} de ${STEPS.length} pasos guardados` + $('#gsteps').innerHTML = STEPS.map(s => { + const done = state.done.includes(s.id) + return `
  1. ${esc(s.name)}${esc(s.say)} (${s.secs} s)
    + ${done ? '✓ Guardado' : ''} +
  2. ` + }).join('') + } + renderSteps() + $('#gsteps').addEventListener('click', e => { + const b = e.target.closest('[data-step]') + if (b && !rec) start(STEPS.find(s => s.id === b.dataset.step)) + }) + $('#gdev').addEventListener('change', e => { device = e.target.value.trim() || guessDevice(); localStorage.setItem('gyrolabDevice', device) }) + $('#gnew').addEventListener('click', () => { state = newSession(); save(state); renderSteps(); toast('Sesión nueva') }) + + // ---- Sensores ---- + const now = e => (e.timeStamp > 0 ? e.timeStamp : performance.now()) + function onOri(e) { + const t = now(e) + lastOri = { a: e.alpha, b: e.beta, g: e.gamma, abs: e.absolute } + hist.ori.push([t, e.alpha, e.beta, e.gamma]) + if (rec?.on) rec.ori.push([r3(t - rec.t0), r3(e.alpha), r3(e.beta), r3(e.gamma)]) + } + function onMot(e) { + const t = now(e), r = e.rotationRate || {}, g = e.accelerationIncludingGravity || {}, a = e.acceleration || {} + if (e.interval != null) interval = e.interval + lastMot = { r, g, a } + hist.mot.push([t, r.alpha, r.beta, r.gamma, g.x, g.y, g.z, a.x, a.y, a.z]) + if (rec?.on) rec.mot.push([r3(t - rec.t0), r3(r.alpha), r3(r.beta), r3(r.gamma), r3(g.x), r3(g.y), r3(g.z), r3(a.x), r3(a.y), r3(a.z)]) + } + const screenNow = () => [screen.orientation?.angle ?? window.orientation ?? 0, innerWidth, innerHeight] + function onScreen() { if (rec?.on) rec.screen.push([r3(performance.now() - rec.t0), ...screenNow()]) } + function listen(on) { + if (on === listening) return + listening = on + const m = on ? 'addEventListener' : 'removeEventListener' + window[m]('deviceorientation', onOri) + window[m]('devicemotion', onMot) + window[m]('resize', onScreen) + screen.orientation?.[m]('change', onScreen) + } + async function keepAwake() { + try { if (!wake && navigator.wakeLock && document.visibilityState === 'visible') { wake = await navigator.wakeLock.request('screen'); wake.addEventListener('release', () => { wake = null }) } } catch {} + } + $('#gperm').addEventListener('click', async () => { + try { audio ||= new (window.AudioContext || window.webkitAudioContext)(); audio.resume() } catch {} + if (!isSecureContext) return msg('Los sensores solo funcionan por HTTPS.') + for (const C of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) { + if (!C?.requestPermission) continue + permission = await C.requestPermission().catch(err => `error: ${err?.message || err}`) + if (permission !== 'granted') return msg(`El navegador no dio permiso para los sensores (${permission}). En iOS: Ajustes → Safari → Movimiento y orientación.`) + } + if (permission === 'sin pedir') permission = 'no hace falta' + listen(true) + keepAwake() + $('#gperm').hidden = true + renderSteps() + msg('Sensores activos. Escoge un paso y toca «Grabar».') + timers.push(setTimeout(() => { + if (!hist.ori.length && !hist.mot.length) msg('No llega ninguna lectura de los sensores. ¿Este dispositivo tiene giroscopio?') + else if (!hist.ori.length) msg('Llega el giroscopio pero no la orientación del sistema.') + else if (!hist.mot.length) msg('Llega la orientación pero no el giroscopio ni el acelerómetro.') + }, 1500)) + }) + const onVis = () => { + if (document.visibilityState === 'visible') { if (listening) keepAwake() } else if (rec) cancel('Se canceló porque la página dejó de verse.') + } + document.addEventListener('visibilitychange', onVis) + + // ---- Avisos sonoros: sirven con el dispositivo sobre la mesa o caminando ---- + function beep(freq = 880, ms = 120, at = 0) { + try { + if (!audio) return + const o = audio.createOscillator(), g = audio.createGain(), t = audio.currentTime + at + o.frequency.value = freq + g.gain.setValueAtTime(0.0001, t) + g.gain.exponentialRampToValueAtTime(0.25, t + 0.01) + g.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000) + o.connect(g).connect(audio.destination) + o.start(t) + o.stop(t + ms / 1000 + 0.02) + } catch {} + } + const buzz = p => { try { navigator.vibrate?.(p) } catch {} } + + // ---- Grabar un paso ---- + let over = null + function overlay(step) { + over = document.createElement('div') + over.className = 'gover' + over.innerHTML = `
    ${esc(step.name)}
    ${esc(step.say)}
    +
    ` + document.body.append(over) + over.querySelector('#gcancel').addEventListener('click', () => cancel('Grabación cancelada.')) + } + function start(step) { + overlay(step) + rec = { step, on: false } + const big = over.querySelector('.gbig') + let left = step.lead + big.textContent = left + beep(660, 80) + const tick = () => { + if (!rec) return + left-- + if (left > 0) { big.textContent = left; beep(660, 80); timers.push(setTimeout(tick, 1000)); return } + big.textContent = 'Grabando' + beep(1040, 220) + buzz(80) + Object.assign(rec, { on: true, t0: performance.now(), ori: [], mot: [], frames: [], screen: [[0, ...screenNow()]], startedAt: Date.now() }) + const bar = over.querySelector('.gbar i'), end = rec.t0 + step.secs * 1000 + const show = () => { + if (!rec?.on) return + const p = Math.min(1, (performance.now() - rec.t0) / (step.secs * 1000)) + bar.style.width = `${p * 100}%` + big.textContent = Math.max(0, Math.ceil((end - performance.now()) / 1000)) + if (p >= 1) finish() + else timers.push(setTimeout(show, 100)) + } + show() + } + timers.push(setTimeout(tick, 1000)) + } + function closeOverlay() { over?.remove(); over = null } + function cancel(text) { rec = null; closeOverlay(); msg(text) } + async function finish() { + const r = rec + rec = null + beep(880, 120); beep(880, 120, 0.2); buzz([60, 60, 60]) + closeOverlay() + const body = { + v: 1, session: state.session, step: r.step.id, device, secs: r.step.secs, startedAt: r.startedAt, + ua: navigator.userAgent, dpr: devicePixelRatio, screenSize: [screen.width, screen.height], interval, permission, + absolute: lastOri?.abs ?? null, + cols: { ori: ['t', 'alpha', 'beta', 'gamma'], mot: ['t', 'rrAlpha', 'rrBeta', 'rrGamma', 'gx', 'gy', 'gz', 'ax', 'ay', 'az'], screen: ['t', 'angle', 'w', 'h'], frames: ['t'] }, + ori: r.ori, mot: r.mot, screen: r.screen, frames: r.frames, + } + msg(`Subiendo «${r.step.name}»: ${r.ori.length} lecturas de orientación y ${r.mot.length} de movimiento…`) + await upload(body, r.step) + } + async function upload(body, step) { + try { + const res = await fetch('/api/gyrolab', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + pending = null + if (!state.done.includes(step.id)) state.done.push(step.id) + save(state) + if (dead) return + renderSteps() + msg(`«${step.name}» guardado (${body.ori.length} + ${body.mot.length} lecturas).`) + } catch (err) { + pending = { body, step } + if (dead) return + msg(`No se pudo subir «${step.name}»: ${err.message}. `) + const b = document.createElement('button') + b.className = 'btn small' + b.textContent = 'Reintentar' + b.addEventListener('click', () => pending && upload(pending.body, pending.step)) + $('#gmsg').append(b) + } + } + + // ---- Vista en vivo ---- + const canvas = $('#gplot'), ctx = canvas.getContext('2d') + const STRIPS = [ + { name: 'Orientación del sistema β γ (°)', src: 'ori', cols: [2, 3], range: 100 }, + { name: 'Giroscopio α β γ (°/s)', src: 'mot', cols: [1, 2, 3], range: 200 }, + { name: 'Acelerómetro con gravedad x y z (m/s²)', src: 'mot', cols: [4, 5, 6], range: 12 }, + ] + const hz = list => { const n = list.length; return n > 1 ? ((n - 1) * 1000) / (list[n - 1][0] - list[0][0]) : 0 } + let liveAt = 0 + function frame(t) { + raf = requestAnimationFrame(frame) + hist.frames.push([t]) + if (rec?.on) rec.frames.push(r3(t - rec.t0)) + const cut = t - KEEP_S * 1000 + for (const k of ['ori', 'mot', 'frames']) { const l = hist[k]; let i = 0; while (i < l.length && l[i][0] < cut) i++; if (i) l.splice(0, i) } + if (rec?.on) return // grabando: no se dibuja nada que no haga falta + const w = Math.round(canvas.clientWidth * devicePixelRatio), h = Math.round(canvas.clientHeight * devicePixelRatio) + if (canvas.width !== w || canvas.height !== h) Object.assign(canvas, { width: w, height: h }) + ctx.clearRect(0, 0, w, h) + const sh = h / STRIPS.length, d = devicePixelRatio + ctx.font = `${11 * d}px system-ui, sans-serif` + STRIPS.forEach((s, i) => { + const y0 = i * sh, mid = y0 + sh / 2 + ctx.strokeStyle = 'rgba(255,255,255,.08)' + ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke() + ctx.fillStyle = 'rgba(255,255,255,.55)' + ctx.fillText(s.name, 6 * d, y0 + 14 * d) + s.cols.forEach((c, j) => { + ctx.strokeStyle = COLORS[j] + ctx.lineWidth = 1.5 * d + ctx.beginPath() + let first = true + for (const row of hist[s.src]) { + const v = row[c] + if (v == null) continue + const x = ((row[0] - cut) / (KEEP_S * 1000)) * w, y = mid - (Math.max(-s.range, Math.min(s.range, v)) / s.range) * (sh / 2 - 4 * d) + first ? ctx.moveTo(x, y) : ctx.lineTo(x, y) + first = false + } + ctx.stroke() + }) + }) + if (t - liveAt < 150) return + liveAt = t + const o = lastOri, m = lastMot + $('#glive').innerHTML = ` +
    Orientación · ${hz(hist.ori).toFixed(0)} Hzα ${fmt(o?.a)}
    β ${fmt(o?.b)}
    γ ${fmt(o?.g)}
    +
    Giroscopio · ${hz(hist.mot).toFixed(0)} Hzα ${fmt(m?.r.alpha)}
    β ${fmt(m?.r.beta)}
    γ ${fmt(m?.r.gamma)}
    +
    Acelerómetro con gravedadx ${fmt(m?.g.x)}
    y ${fmt(m?.g.y)}
    z ${fmt(m?.g.z)}
    +
    Sin gravedadx ${fmt(m?.a.x)}
    y ${fmt(m?.a.y)}
    z ${fmt(m?.a.z)}
    +
    Pantalla · ${hz(hist.frames).toFixed(0)} Hzgiro ${screenNow()[0]}°
    ${innerWidth}×${innerHeight}
    intervalo ${interval ?? '—'} ms
    ` + } + raf = requestAnimationFrame(frame) + + return () => { + dead = true + cancelAnimationFrame(raf) + timers.forEach(clearTimeout) + rec = null + closeOverlay() + listen(false) + document.removeEventListener('visibilitychange', onVis) + try { wake?.release() } catch {} + try { audio?.close() } catch {} + style.remove() + } +} diff --git a/app/public/index.html b/app/public/index.html index 7ea0308..b29080c 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -23,6 +23,7 @@