Replace the mobile sidebar with a bottom sheet, grouped by data shape #10

Merged
kyuno merged 2 commits from feature/mobile-nav into master 2026-09-16 15:55:57 +02:00
6 changed files with 385 additions and 35 deletions

View file

@ -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)

View file

@ -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);

View file

@ -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; }
}

View file

@ -6,8 +6,37 @@
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav>
<button class="linklike sidebarLogout" id="logout"></button>
<button class="linklike sidebarLogout logoutBtn" id="logout"></button>
</aside>
<!-- Mobile-only (see Shell.css's 720px breakpoint) - the sidebar
above is display:none there instead of reshaping into a
horizontal bar. See docs/architecture.md's "Mobile navigation". -->
<header class="mobileTopBar">
<span class="mobileBrand">Watch Party</span>
</header>
<main id="mainContent" class="mainContent"></main>
<div id="musicBar" class="musicBar"></div>
<!-- Wraps #musicBar so the trigger below always sits directly above
whatever height the bar actually renders at - see Shell.css. -->
<div class="bottomStack">
<div id="musicBar" class="musicBar"></div>
<button class="mobileNavTrigger" id="mobileNavTrigger" aria-expanded="false" aria-controls="mobileNavSheet">
Parcourir
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div class="mobileNavScrim" id="mobileNavScrim"></div>
<div class="mobileNavSheet" id="mobileNavSheet">
<div class="sheetGrabber"></div>
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
<div class="navSectionLabel">Bibliothèques</div>
<div class="sheetGrid" id="mobileLibGrid"></div>
<div id="mobilePlaylistsSlot"></div>
<button class="linklike sheetLogout logoutBtn" id="mobileLogout"></button>
</div>
</div>

View file

@ -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 = '<svg viewBox="0 0 24 24"><path d="M3.5 11.5 12 4l8.5 7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 10v9a1 1 0 0 0 1 1H10v-5.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V20h3.5a1 1 0 0 0 1-1v-9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const MOVIES_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="6" width="17" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3.5 6 7 3h3l-2.5 3M11 6l2.5-3h3L14 6m3.5 0 2.5-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 20.5h6M12 17.5v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></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 = '<svg viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const COLLECTION_ICONS: Record<string, string> = {
movies: MOVIES_ICON_SVG,
tvshows: SHOWS_ICON_SVG,
music: MUSIC_ICON_SVG,
musicvideos: MUSICVIDEOS_ICON_SVG,
playlists: PLAYLISTS_ICON_SVG,
};
/** One `<a>` 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<HTMLElement> {
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<HTMLButtonElement>('.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<HTMLElement> {
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;
}

View file

@ -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