diff --git a/README.md b/README.md index be29cf2..88527db 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,9 @@ Then put it behind your existing reverse proxy like everything else movie-poster tiles — circular artist tiles, square album tiles, a real hero header, and a mobile "now playing" full-screen player — see docs/architecture.md +- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag + handle, not just a decorative grabber) instead of fighting the + browser's own pull-to-refresh gesture — see docs/architecture.md - [x] Every blocking `alert()` (host handoff, every "couldn't start/join a party" error) replaced with a light, auto-dismissing toast — see docs/architecture.md diff --git a/client/src/shell/Shell.css b/client/src/shell/Shell.css index e2ae3d1..09a2301 100644 --- a/client/src/shell/Shell.css +++ b/client/src/shell/Shell.css @@ -121,6 +121,12 @@ opacity: 0; pointer-events: none; transition: opacity .2s; + /* A touch starting here has no scrollable content of its own to + "contain" (overscroll-behavior wouldn't apply) - this is what + stops the browser reading a drag over the dimmed backdrop as a + page-level gesture (pull-to-refresh) instead of just dismissing + the sheet via the existing tap-to-close below. */ + touch-action: none; } .mobileNavScrim.open { opacity: 1; pointer-events: auto; } @@ -133,6 +139,12 @@ z-index: 21; max-height: 75vh; overflow-y: auto; + /* Stops a drag that hits the top of the sheet's OWN scroll (its + library grid can be taller than 75vh) from chaining past this + element to the page underneath and surfacing as pull-to-refresh - + complements touch-action:none on the dedicated grabber handle + below, which instead covers a drag that never scrolls anything. */ + overscroll-behavior: contain; background: var(--bg-header); border-radius: 16px 16px 0 0; padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px)); @@ -143,12 +155,27 @@ .mobileNavSheet.open { transform: translateY(0); } +/* Dragging (see Shell.ts) suspends the transition above for 1:1 + tracking under the finger, then this class restores it for the + snap-back/close animation - same "instant while dragging, animated + on release" split views/player/Player.css's own seek bar uses. */ +.mobileNavSheet.dragging { transition: none; } + +/* Bigger drag/touch target than the pill it contains (36x4px is far + below a real touch-target minimum) - see Shell.html/Shell.ts. */ +.sheetGrabberHandle { + display: flex; + justify-content: center; + padding: 14px 0 10px; + touch-action: none; + cursor: grab; +} + .sheetGrabber { width: 36px; height: 4px; border-radius: 99px; background: var(--muted); - margin: 10px auto 6px; } /* Accueil and the (conditional) Listes de lecture row both use this - diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 4d2d356..df0fa01 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -32,7 +32,14 @@
-
+ +
Accueil
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index cc06086..8d83edb 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -105,7 +105,7 @@ export async function renderShell(appEl: HTMLElement): Promise { // ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ---- const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement; - const navSheet = appEl.querySelector('#mobileNavSheet')!; + const navSheet = appEl.querySelector('#mobileNavSheet') as HTMLElement; const navScrim = appEl.querySelector('#mobileNavScrim')!; const closeMobileNav = () => { navSheet.classList.remove('open'); @@ -127,5 +127,39 @@ export async function renderShell(appEl: HTMLElement): Promise { }); window.addEventListener('hashchange', closeMobileNav); + // Drag-to-dismiss on the grabber handle only - not the whole sheet, + // which would fight the library grid's own scroll/tap targets (see + // docs/architecture.md's "Mobile navigation, take two"). Reported + // directly: without this, a scrim tap was the ONLY way to close the + // sheet, and the instinctive swipe-it-down gesture instead fell + // through to the browser's own pull-to-refresh (see Shell.css's + // touch-action/overscroll-behavior on the scrim/sheet themselves - + // this is the other half, giving that swipe something to actually do). + const CLOSE_DRAG_THRESHOLD_PX = 90; + const grabberHandle = appEl.querySelector('#sheetGrabberHandle') as HTMLElement; + let dragStartY: number | null = null; + let dragDeltaY = 0; + + grabberHandle.addEventListener('pointerdown', (e) => { + dragStartY = e.clientY; + dragDeltaY = 0; + grabberHandle.setPointerCapture(e.pointerId); + navSheet.classList.add('dragging'); + }); + grabberHandle.addEventListener('pointermove', (e) => { + if (dragStartY === null) return; + dragDeltaY = Math.max(0, e.clientY - dragStartY); // only follows a downward drag - closing is the only thing this gesture does + navSheet.style.transform = `translateY(${dragDeltaY}px)`; + }); + function endDrag() { + if (dragStartY === null) return; + dragStartY = null; + navSheet.classList.remove('dragging'); + navSheet.style.transform = ''; // hands back to the .open class's own rule - either translateY(0) (snap back) or the closeMobileNav() call below immediately overrides it anyway + if (dragDeltaY > CLOSE_DRAG_THRESHOLD_PX) closeMobileNav(); + } + grabberHandle.addEventListener('pointerup', endDrag); + grabberHandle.addEventListener('pointercancel', endDrag); + return appEl.querySelector('#mainContent') as HTMLElement; } diff --git a/docs/architecture.md b/docs/architecture.md index 9cc70cd..e1c29b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3775,6 +3775,61 @@ genuinely narrow viewport. **Not verified**: any of this against a real Emby server's actual artist/album images and names, or on a real phone. +## Mobile navigation, take two: swipe-to-close was fighting pull-to-refresh + +Reported directly, against a real phone: the bottom sheet (see "Mobile +navigation" above) had exactly one way to close it - tap the dimmed +scrim - since the grabber was purely decorative. Swiping the sheet +down, the instinctive gesture every real bottom sheet supports, fell +through to the browser's own default touch handling instead: with +nothing telling it otherwise, a downward drag starting on the scrim or +the sheet reads to the browser as "the user wants to pull the page +down," which on a phone is the built-in pull-to-refresh gesture - not +a bug in this app's own logic, but a real gap in it (nothing was +`preventDefault`-ing or claiming that gesture for the sheet instead). + +**Two independent parts, not one fix**: containing the browser's own +gesture, and giving the swipe something to actually do. + +- `touch-action: none` on `.mobileNavScrim` and the new + `.sheetGrabberHandle` - tells the browser not to apply *any* of its + own default touch behavior (panning, pull-to-refresh) to a touch + starting on either, since this app's own pointer handlers own that + gesture now. `overscroll-behavior: contain` on `.mobileNavSheet` + itself covers the other half of the same problem: its own library + grid can be taller than the sheet's `75vh` cap, so a drag that hits + *that* scroll boundary needs to stop there too, not chain past the + sheet to the page underneath. +- **The grabber is now a real drag handle**, not just a visual hint. + `#sheetGrabberHandle` wraps the small visible pill in a much bigger + invisible touch target (a real touch target, not a 36x4px sliver) + and drives the sheet with the same pointerdown/move/up(/cancel) + pattern `views/player/Player.css`'s own seek bar already uses: + 1:1 tracking under the finger while dragging (`.dragging` suspends + the CSS transition for this), then either snapping back open (a + drag under 90px) or calling the exact same `closeMobileNav()` the + scrim tap already used (past it) - one close path, two ways to + reach it, not a second parallel implementation. Deliberately scoped + to the grabber alone, not the whole sheet: the sheet's own content + (the library grid, the logout row) needs normal taps and its own + scroll to keep working, which a whole-sheet drag-to-close would + fight. + +Verified: full `typecheck`/`build` (client only). The CSS properties +(`touch-action`/`overscroll-behavior`) confirmed applied via +`getComputedStyle` against the real compiled `app.css`. The drag logic +itself confirmed with dispatched `PointerEvent`s carrying real +`clientY`/`pointerId` values against the real handlers copied from +`Shell.ts`: a 40px drag snaps back (stays open, `transform` cleared), +a 120px drag closes, and the live `transform` mid-drag tracks the +pointer 1:1 (confirmed at an intermediate point, not just the start/end +state) - not just asserted from reading the threshold constant. +Existing behavior re-confirmed unchanged in the same pass: scrim-tap- +to-close still closes. **Not verified**: actual touch input (not a +mouse or dispatched `PointerEvent`) on a real phone - no device +reachable from this sandbox, same standing gap as this project's other +touch-specific work (the video/music seek bars' own drag handling). + ## Toast notifications, replacing every blocking `alert()` Asked for directly, prompted by the host-handoff message specifically