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 @@
Bibliothèques
Chargement...
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
Accueil
+
Bibliothèques
+
+
+
+
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