Compare commits
3 commits
b5a9a0bc7d
...
e9b6a2541c
| Author | SHA1 | Date | |
|---|---|---|---|
| e9b6a2541c | |||
| 2f10a46664 | |||
| 40a897cf1b |
6 changed files with 385 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue