diff --git a/public/lyrics-view.js b/public/lyrics-view.js index 2f4442f..197a775 100644 --- a/public/lyrics-view.js +++ b/public/lyrics-view.js @@ -81,7 +81,7 @@ function lyrOpen() { const t = cur(); if (!t || lyr.open) return; lyr.open = true; document.body.classList.add('lyropen'); const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics'); - document.body.appendChild(el); lyrShell(); lyrReload(); + document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true }); addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick); } function lyrClose() { diff --git a/public/sheets.js b/public/sheets.js index 9477fc3..17ca0af 100644 --- a/public/sheets.js +++ b/public/sheets.js @@ -5,13 +5,38 @@ function openSheet(title, sub, bodyHtml) { const el = document.createElement('div'); el.id = 'sheet'; el.innerHTML = `
${esc(sub)}
` : ''}${bodyHtml}${esc(sub)}
` : ''}${bodyHtml}`; document.body.appendChild(el); - el.querySelector('.scrim').onclick = closeSheet; + el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet; + swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') }); requestAnimationFrame(() => el.classList.add('on')); return el; } function closeSheet() { document.getElementById('sheet')?.remove(); } +/** Close with the slide-down (the close button, a tap outside); closeSheet() is the instant one other code calls before opening something else. */ +function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) s.remove(); }, 230); } +/** Swipe down to dismiss: `el` follows the finger and `close` runs when it is let go far enough or fast enough. Touches starting in `handle` always count; elsewhere only when nothing under the finger is scrolled down, and never on sliders or text fields. On a phone-sized screen only, unless `always` (full-screen views). */ +function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) { + let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true; + const reset = () => { el.style.transition = 'transform .2s ease'; el.style.transform = ''; if (scrim) { scrim.style.transition = 'opacity .2s'; scrim.style.opacity = ''; } setTimeout(() => { el.style.transition = ''; if (scrim) scrim.style.transition = ''; }, 220); }; + el.addEventListener('touchstart', e => { + on = false; dead = true; if (e.touches.length !== 1 || (!always && !matchMedia('(max-width: 767px)').matches)) return; + const t = e.target; if (t.closest && t.closest('input, textarea, select, [contenteditable], [data-nodrag]')) return; + if (!(handle && t.closest && t.closest(handle))) for (let n = t; n; n = n.parentElement) { if (n.scrollTop > 0) return; if (n === el) break; } + y0 = e.touches[0].clientY; x0 = e.touches[0].clientX; t0 = Date.now(); dead = false; + }, { passive: true }); + el.addEventListener('touchmove', e => { + if (dead) return; const dy = e.touches[0].clientY - y0, dx = e.touches[0].clientX - x0; + if (!on) { if (dy > 10 && dy > Math.abs(dx) * 1.5) { on = true; el.style.transition = 'none'; if (scrim) scrim.style.transition = 'none'; } else if (Math.abs(dx) > 10 || dy < -6) { dead = true; return; } else return; } + if (e.cancelable) e.preventDefault(); + const d = Math.max(0, dy); el.style.transform = 'translateY(' + d + 'px)'; if (scrim) scrim.style.opacity = String(Math.max(0, 1 - d / 420)); + }, { passive: false }); + const end = e => { + if (!on) return; on = false; dead = true; const dy = (e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientY : y0) - y0, fast = dy / Math.max(1, Date.now() - t0) > 0.6; + if (e.type !== 'touchcancel' && (dy > 110 || (fast && dy > 30))) { el.style.transition = 'transform .18s ease'; el.style.transform = 'translateY(105%)'; if (scrim) { scrim.style.transition = 'opacity .18s'; scrim.style.opacity = '0'; } setTimeout(close, 170); } else reset(); + }; + el.addEventListener('touchend', end); el.addEventListener('touchcancel', end); +} addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); }); function actionSheet(title, sub, actions) { diff --git a/public/style.css b/public/style.css index f7f9c7a..9c09cab 100644 --- a/public/style.css +++ b/public/style.css @@ -123,6 +123,8 @@ label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px padding: 8px 16px calc(var(--sab) + 16px); transform: translateY(100%); transition: transform .22s ease; max-height: 85dvh; overflow-y: auto; } #sheet.on .scrim { opacity: 1; } #sheet.on .sheet { transform: none; } +#sheet .sheetx { position: sticky; top: 0; float: right; z-index: 2; width: 36px; height: 36px; margin: 0 -6px 0 8px; background: var(--panel2); border-radius: 50%; } #sheet .sheetx svg { width: 18px; height: 18px; } +#sheet .sheet { overscroll-behavior: contain; } #sheet .grip { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 4px auto 12px; } #sheet h3 { margin: 0 0 4px; font-size: 18px; word-break: break-all; } #sheet .list { background: var(--panel2); border: 0; margin-top: 8px; } @@ -1070,11 +1072,11 @@ body.vizopen { overflow: hidden; } #devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; } .crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; } -.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; } - .crtick.goal { width: 4px; background: var(--accent); opacity: 1; } .crgoal { display: flex; align-items: center; gap: 12px; margin: 8px 0 4px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); } .crgoal .crgi { font-size: 26px; } .crgoal .grow { min-width: 0; } .crgoal b, .crgoal small { display: block; } .crgoal small { color: var(--muted); } .crgoalbtn { margin: 4px 0 0 52px; min-height: 36px; font-size: 14px; } +.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; } + #kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px + var(--playerh, 0px)); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; } @media (min-width: 900px) { #kidreq { bottom: calc(16px + var(--playerh, 0px)); left: 236px; } } diff --git a/public/sw.js b/public/sw.js index b180e47..40d9d61 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v90'; +const CACHE = 'hub-v94'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/visualizer.js b/public/visualizer.js index cf5ad14..fa7343d 100644 --- a/public/visualizer.js +++ b/public/visualizer.js @@ -169,7 +169,7 @@ function vizOpen() { const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer'); el.innerHTML = `