From 40a897cf1b98e4ee9254c4c1dd42ea4252c1ce51 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 14:56:46 +0200 Subject: [PATCH] Replace the mobile sidebar with a bottom sheet, grouped by data shape The old mobile treatment just reshaped the sidebar into a horizontally scrolling top bar - easy to miss that there's more to scroll to, and "Se deconnecter" ended up buried at the far end of it. Explored as mockups first (compared against a bottom tab bar, a hamburger drawer, and a polished version of the existing bar) before writing this. The sheet mirrors the real data shape instead of a flat list: Accueil and Listes de lecture (when a playlists-type library exists) are fixed rows bookending a Bibliotheques group, which is the part that actually varies with whatever the connected Emby server returns - zero new server work, since getLibraries() already sends collectionType per library, just unused on the client until now. The music bar's stacking under the new nav trigger is now structural (a shared position:fixed flex column) instead of a hardcoded offset, since the bar's own height isn't constant - its row can wrap on a narrow screen with a long track/artist name. Co-Authored-By: Claude Sonnet 5 --- README.md | 3 + client/src/musicBar/MusicBar.css | 12 +-- client/src/shell/Shell.css | 170 ++++++++++++++++++++++++++++--- client/src/shell/Shell.html | 33 +++++- client/src/shell/Shell.ts | 106 ++++++++++++++++--- docs/architecture.md | 96 +++++++++++++++++ 6 files changed, 385 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9024002..0a9db69 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,9 @@ Then put it behind your existing reverse proxy like everything else action — resolved server-side (one atomic operation, not one request per track) the same way launching a playlist already expands into everything it contains — see docs/architecture.md +- [x] Mobile navigation redesigned as a bottom sheet (Accueil / + Bibliothèques / Listes de lecture) instead of a horizontally + scrolling sidebar — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/client/src/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css index 4ec2e9f..c81c064 100644 --- a/client/src/musicBar/MusicBar.css +++ b/client/src/musicBar/MusicBar.css @@ -6,13 +6,13 @@ able to browse/add tracks while it plays). Sits above .mainContent, at a lower z-index than .playerWrap (20) so a video party's takeover still correctly covers it - moot in practice since joining a video party - calls leaveMusicParty() first (see Player.ts), but cheap to get right. */ + calls leaveMusicParty() first (see Player.ts), but cheap to get right. + The fixed-to-viewport positioning itself lives one level up, on + Shell.css's .bottomStack wrapper - not here - so that on a narrow + screen the mobile nav trigger right below it in that wrapper always + sits flush above this bar's own actual rendered height (its content + can wrap taller on a narrow screen), never a hardcoded guess at it. */ .musicBar { - position: fixed; - left: 0; - right: 0; - bottom: 0; - z-index: 15; background: var(--bg-header); border-top: 1px solid rgba(255, 255, 255, .08); box-shadow: 0 -4px 16px rgba(0, 0, 0, .3); diff --git a/client/src/shell/Shell.css b/client/src/shell/Shell.css index ac083cb..e2ae3d1 100644 --- a/client/src/shell/Shell.css +++ b/client/src/shell/Shell.css @@ -62,20 +62,162 @@ padding: 24px clamp(16px, 3vw, 48px) 80px; } -/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */ +/* ==== Mobile navigation (<=720px) ==== + Replaces the sidebar outright - see docs/architecture.md's "Mobile + navigation" section for why a bottom sheet was chosen over reshaping + the sidebar into a horizontal bar. Every rule below the base + `display: none` only ever applies once the 720px media query turns + it back on, so none of this affects desktop. */ +.mobileTopBar, +.mobileNavTrigger, +.mobileNavScrim, +.mobileNavSheet { + display: none; +} + +.mobileTopBar { + position: sticky; + top: 0; + z-index: 3; + padding: 14px 16px; + background: var(--bg-header); +} + +.mobileBrand { font-size: 16px; font-weight: 700; } + +/* position:fixed, not sticky - #musicBar below already relies on a + fixed wrapper to stay docked regardless of how far #mainContent's + own (whole-page, not internally-scrolled) content runs. */ +.bottomStack { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 15; + display: flex; + flex-direction: column; +} + +.mobileNavTrigger { + align-items: center; + justify-content: center; + gap: 8px; + background: var(--bg-header); + border: none; + border-top: 1px solid rgba(255, 255, 255, .08); + color: var(--text); + font-family: inherit; + font-size: 13px; + font-weight: 600; + padding: 13px; + padding-bottom: calc(13px + env(safe-area-inset-bottom, 0px)); +} + +.mobileNavScrim { + position: fixed; + inset: 0; + z-index: 20; + background: rgba(0, 0, 0, .5); + opacity: 0; + pointer-events: none; + transition: opacity .2s; +} + +.mobileNavScrim.open { opacity: 1; pointer-events: auto; } + +.mobileNavSheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 21; + max-height: 75vh; + overflow-y: auto; + background: var(--bg-header); + border-radius: 16px 16px 0 0; + padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px)); + transform: translateY(100%); + transition: transform .22s ease; + box-shadow: 0 -8px 24px rgba(0, 0, 0, .3); +} + +.mobileNavSheet.open { transform: translateY(0); } + +.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 - + same visual weight, bookending the "Bibliothèques" group between + them, so the one part whose size actually varies (however many + libraries Emby returns) reads as visually distinct from the two + fixed rows around it. */ +.sheetPrimary { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: var(--panel); + border: none; + border-radius: 10px; + color: var(--text); + font-family: inherit; + font-size: 14px; + font-weight: 600; + padding: 12px; + margin: 4px 0; + text-decoration: none; +} + +.sheetPrimary.active { background: var(--accent); color: #fff; } + +.sheetGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 4px 0 10px; +} + +.sheetItem { + display: flex; + align-items: center; + gap: 9px; + background: var(--panel); + border-radius: 10px; + color: var(--text); + font-size: 13px; + padding: 12px 10px; + text-decoration: none; +} + +.sheetItem.active { background: var(--accent); color: #fff; font-weight: 600; } + +.navIcon { width: 20px; height: 20px; flex-shrink: 0; display: block; } +.navIcon svg { width: 100%; height: 100%; } + +.sheetLogout { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 8px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, .15); + border-radius: 10px; + color: var(--muted); + font-size: 13px; +} + @media (max-width: 720px) { .appShell { flex-direction: column; } - .sidebar { - width: 100%; - height: auto; - flex-direction: row; - align-items: center; - padding: 10px 12px; - overflow-x: auto; - overflow-y: hidden; - } - .sidebarBrand { padding: 0 12px 0 0; } - .sidebarNav { flex-direction: row; flex: none; } - .navSectionLabel { display: none; } - .sidebarLogout { margin: 0 0 0 12px; } + .sidebar { display: none; } + .mobileTopBar { display: flex; align-items: center; } + .mobileNavTrigger { display: flex; } + .mobileNavScrim { display: block; } + .mobileNavSheet { display: block; } } diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 9fa156a..4d2d356 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -6,8 +6,37 @@

Chargement...

- + + + +
+ Watch Party +
+
-
+ + +
+
+ +
+ +
+
+
+ Accueil + +
+
+ +
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index f10797d..cc06086 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -5,41 +5,97 @@ import './Shell.css'; import html from './Shell.html'; import { auth, getLibraries, logout } from '../api/embyApi'; import { initMusicBar } from '../musicBar/MusicBar'; +import type { LibraryDto } from '../types'; + +// One icon per Emby CollectionType - only used by the mobile bottom +// sheet (see docs/architecture.md's "Mobile navigation"); the desktop +// sidebar stays plain text, unchanged. Same small inline-SVG-constant +// convention as views/player/Player.ts's own icon strings. +const HOME_ICON_SVG = ''; +const MOVIES_ICON_SVG = ''; +const SHOWS_ICON_SVG = ''; +const MUSIC_ICON_SVG = ''; +const MUSICVIDEOS_ICON_SVG = ''; +const PLAYLISTS_ICON_SVG = ''; +// Emby could in principle add a CollectionType this app doesn't map yet +// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES +// would need updating first either way) - falls back to a plain folder +// glyph rather than rendering an empty icon slot. +const GENERIC_LIBRARY_ICON_SVG = ''; + +const COLLECTION_ICONS: Record = { + movies: MOVIES_ICON_SVG, + tvshows: SHOWS_ICON_SVG, + music: MUSIC_ICON_SVG, + musicvideos: MUSICVIDEOS_ICON_SVG, + playlists: PLAYLISTS_ICON_SVG, +}; + +/** One `` per library, shared by the desktop sidebar (plain text) + * and the mobile sheet (icon + text) - `withIcon` is the only + * difference between the two call sites below. */ +function createLibraryLink(lib: LibraryDto, className: string, withIcon: boolean): HTMLAnchorElement { + const a = document.createElement('a'); + a.href = `#/library/${lib.id}`; + a.className = className; + if (withIcon) { + const icon = document.createElement('span'); + icon.className = 'navIcon'; + icon.innerHTML = COLLECTION_ICONS[lib.collectionType] || GENERIC_LIBRARY_ICON_SVG; + a.appendChild(icon); + } + a.appendChild(document.createTextNode(lib.name)); + return a; +} export async function renderShell(appEl: HTMLElement): Promise { appEl.innerHTML = html; - appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`; + + appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG; // Mounted once, outside #mainContent, so a listen party survives // navigating the router - see musicBar/MusicBar.ts's header comment. initMusicBar(appEl.querySelector('#musicBar') as HTMLElement); - appEl.querySelector('#logout')!.addEventListener('click', async () => { - await logout(); - // Simplest reliable way back to a bare login screen: a real reload, - // rather than hand-tearing-down every shell/router listener in place. - location.hash = ''; - location.reload(); + // Both the sidebar's and the mobile sheet's logout button - same + // label, same handler, one line each instead of a shared DOM node + // fought over by two very differently-laid-out containers. + appEl.querySelectorAll('.logoutBtn').forEach((btn) => { + btn.textContent = `Se déconnecter (${auth.userName || ''})`; + btn.addEventListener('click', async () => { + await logout(); + // Simplest reliable way back to a bare login screen: a real reload, + // rather than hand-tearing-down every shell/router listener in place. + location.hash = ''; + location.reload(); + }); }); const nav = appEl.querySelector('#sidebarNav')!; const status = appEl.querySelector('#libStatus')!; + const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!; + const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!; try { const libraries = await getLibraries(); status.remove(); libraries.forEach((lib) => { - const a = document.createElement('a'); - a.href = `#/library/${lib.id}`; - a.className = 'navItem'; - a.textContent = lib.name; - nav.appendChild(a); + nav.appendChild(createLibraryLink(lib, 'navItem', false)); + // 'playlists' gets pulled out of the "Bibliothèques" group into + // its own fixed row - see docs/architecture.md. Everything else + // (movies/tvshows/music/musicvideos, whatever mix this server + // actually has) lands in the grid, unchanged in count or order. + if (lib.collectionType === 'playlists') { + mobilePlaylistsSlot.appendChild(createLibraryLink(lib, 'navItem sheetPrimary', true)); + } else { + mobileLibGrid.appendChild(createLibraryLink(lib, 'navItem sheetItem', true)); + } }); } catch { status.textContent = 'Bibliothèques indisponibles.'; } const highlight = () => { - nav.querySelectorAll('.navItem').forEach((a) => { + appEl.querySelectorAll('.navItem').forEach((a) => { const isHome = a.getAttribute('href') === '#/home'; a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); }); @@ -47,5 +103,29 @@ export async function renderShell(appEl: HTMLElement): Promise { highlight(); window.addEventListener('hashchange', highlight); + // ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ---- + const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement; + const navSheet = appEl.querySelector('#mobileNavSheet')!; + const navScrim = appEl.querySelector('#mobileNavScrim')!; + const closeMobileNav = () => { + navSheet.classList.remove('open'); + navScrim.classList.remove('open'); + navTrigger.setAttribute('aria-expanded', 'false'); + }; + navTrigger.addEventListener('click', () => { + const opening = !navSheet.classList.contains('open'); + navSheet.classList.toggle('open', opening); + navScrim.classList.toggle('open', opening); + navTrigger.setAttribute('aria-expanded', String(opening)); + }); + navScrim.addEventListener('click', closeMobileNav); + // Any of the three zones (Accueil, a library, Listes de lecture) - + // the sheet itself has no other clickable children, so this one + // delegated listener covers all of them without a per-link handler. + navSheet.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('.navItem')) closeMobileNav(); + }); + window.addEventListener('hashchange', closeMobileNav); + return appEl.querySelector('#mainContent') as HTMLElement; } diff --git a/docs/architecture.md b/docs/architecture.md index 96bc008..2b31560 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3419,6 +3419,102 @@ tokens) rather than eyeballed. **Not verified**: an actual large discography queuing correctly end to end against a real server - no Emby login reachable from this sandbox. +## Mobile navigation: a bottom sheet, not a reshaped sidebar + +Asked for directly: the sidebar's old mobile treatment just reshaped +itself into a horizontally-scrolling top bar under 720px (a single +media query in Shell.css) - functional, but easy to miss (nothing +signals there's more to scroll to) and it buried "Se déconnecter" at +the far end of that scroll. Explored as a set of mockups first (four +patterns compared side by side, including the tradeoffs of each) before +writing any real code - the bottom sheet won out over a bottom tab bar +(too few slots for an unbounded number of Emby libraries), a hamburger +drawer (still the smallest code delta, but two taps for "Accueil" too), +and a merely-polished version of the existing horizontal bar. + +**Structure mirrors the real data shape, not a flat list**: `getLibraries()` +already returns each library's Emby `CollectionType` as `collectionType` +on `LibraryDto` (`server/src/services/CatalogService.ts`) - confirmed by +reading that service directly rather than assumed. The sheet uses it to +render three zones instead of one flat list: + +- **Accueil** - one fixed row, always first. +- **Bibliothèques** - a section label over a 2-column grid of every + library *except* the one whose `collectionType` is `'playlists'` - + this is the part whose count and contents genuinely depend on what + the connected Emby server has (2 libraries or 10, movies-only or + every type at once). +- **Listes de lecture** - pulled out of that grid into its own fixed + row, same visual weight as Accueil, bookending the group. Only + rendered at all when a `'playlists'`-type library is actually + present - same conditional-presence rule the sidebar already applies + to every library type, just applied one level deeper here. + +Zero server-side work for this split - `collectionType` was already +being sent, just never read on the client before now. + +**One `createLibraryLink()` builds both trees.** The desktop sidebar's +existing plain-text links and the mobile sheet's icon+text links are +different enough (icon or not, grid cell or plain row) that unifying +them into one shared DOM subtree styled two ways would need more CSS +trickery than it saves - so `Shell.ts` keeps two small render call +sites, both reading the one `getLibraries()` result, sharing the one +link-building function. The two logout buttons (sidebar footer, sheet +footer) are separate DOM nodes for the same reason - same label, same +`click` handler, wired once via `.logoutBtn` rather than `#logout`. + +**Icons are new** - the sidebar has never had any. Small inline SVGs, +same "constant string, `currentColor`" convention `views/player/Player.ts` +already uses for its own icons, one per Emby `CollectionType` +(`movies`/`tvshows`/`music`/`musicvideos`/`playlists`) plus a generic +fallback for any future type this app hasn't mapped yet. + +**The music bar's stacking, made structural instead of a magic number.** +`MusicBar.css`'s `.musicBar` no longer positions itself - a new +`.bottomStack` (`position: fixed; bottom: 0`, plain flex column) wraps +it together with the new mobile nav trigger button, so the trigger +always sits flush above the bar's *actual* rendered height. That +height isn't constant - `.musicBarRow` wraps onto a second line on a +narrow screen with a long track/artist name plus presence avatars - +so a hardcoded pixel offset would have drifted; normal flow inside one +shared fixed wrapper can't. Transparent to desktop: the trigger is +`display: none` there, so `.bottomStack` just becomes a plain wrapper +around the bar, unchanged from before. + +The sheet itself (`.mobileNavSheet`) and its scrim are independent +`position: fixed` overlays above `.bottomStack`'s z-index, sliding up +via `transform: translateY` - closes on: tapping the scrim, tapping any +of the three zones' links (one delegated click listener, not one per +link), or any `hashchange` (covers browser back/forward too, alongside +the existing `highlight()` listener that already ran on that event for +the active-link state - now widened from `#sidebarNav .navItem` to +every `.navItem` on the page, so both trees highlight consistently). + +Verified: full `typecheck`/`build` (client only). The interaction logic +- open/close, closing on a link tap, active-state highlighting, the +grouped Accueil/Bibliothèques/Listes-de-lecture split, the conditional +playlists row - was proven first in the mockup artifact (identical +logic), then re-verified here directly against the real shipped +`Shell.ts`/`Shell.html`/compiled `Shell.css`, wired up with fake library +data (no Emby login reachable from this sandbox) since the real Shell +only ever mounts after a real login: the desktop tree still renders +correctly untouched at desktop width, the compiled CSS's media query +and base mobile rules match what was written byte-for-byte, and the +mobile sheet's open/close/navigate/logout interactions were driven +programmatically end to end (`element.click()` still fires listeners on +a `display: none` element, so this didn't need a real narrow viewport +to exercise the logic itself). The music-bar/trigger stacking was +confirmed visually with a synthetic long-track-title music bar sitting +flush above the trigger, no gap or overlap. **Not verified**: the +actual rendered layout at a genuinely narrow viewport - this sandbox's +browser tool's viewport-emulation stopped taking effect partway through +this session (`screen.width` reflected the requested size, `innerWidth` +and `matchMedia` did not - a tooling issue hit repeatedly this session, +not something in this app's code), so the mobile-width visual check +above was done by forcing the same CSS rules through an injected +override instead of a real ≤720px viewport. Worth an early real-phone +check once deployed. + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's -- 2.45.2