diff --git a/README.md b/README.md index 55edfb5..d15c034 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,9 @@ Then put it behind your existing reverse proxy like everything else - [x] Installable as a PWA (manifest + icons, standalone display, no service worker — this app has nothing worth caching offline) — 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 eebd73d..329bdfe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3561,6 +3561,102 @@ no device reachable from this sandbox, same standing gap as every other browser-native integration in this project (casting, PiP, the intro-skip buttons). +## 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